SpringSecurity认证授权
一、SpringSecurity3
1、引入依赖(Gradle)
1 2 implementation 'com.auth0:java-jwt:3.4.0' implementation 'org.springframework.boot:spring-boot-starter-security'
2、配置信息
跨域:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 @Configuration public class CorsConfig { @Bean public CorsFilter corsFilter () { final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource (); final CorsConfiguration config = new CorsConfiguration (); config.setAllowCredentials(true ); config.setAllowedOriginPatterns(Collections.singletonList("*" )); config.setAllowedHeaders(Collections.singletonList("*" )); config.setAllowedMethods(Collections.singletonList("*" )); List<String> list = new ArrayList <>(); list.add("Content-Disposition" ); config.setExposedHeaders(list); config.setMaxAge(300L ); source.registerCorsConfiguration("/**" , config); return new CorsFilter (source); } }
config
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 @Configuration @EnableWebSecurity @EnableMethodSecurity public class WebSecurityConfig { @Autowired private UserDetailsService userDetailsService; @Autowired private JwtFilter jwtFilter; @Autowired HandlerExceptionResolver handlerExceptionResolver; @Bean AuthenticationManager authenticationManager () throws Exception{ DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider (); daoAuthenticationProvider.setUserDetailsService(userDetailsService); daoAuthenticationProvider.setPasswordEncoder(new BCryptPasswordEncoder ()); return new ProviderManager (daoAuthenticationProvider); } @Bean SecurityFilterChain filterChain (HttpSecurity http) throws Exception { http.formLogin(AbstractHttpConfigurer::disable); http.httpBasic(AbstractHttpConfigurer::disable); http.csrf(AbstractHttpConfigurer::disable); http.cors(Customizer.withDefaults()); http.authorizeHttpRequests( req-> req.requestMatchers("/user/login" ).permitAll() .requestMatchers("/category/all" ).permitAll() .requestMatchers("/book/category" ).permitAll() .requestMatchers("/book/book" ).permitAll() .requestMatchers("/book/search" ).permitAll() .anyRequest().authenticated() ); http.exceptionHandling(exp->exp.authenticationEntryPoint(new AuthenticationEntryPoint () { @Override public void commence ( HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { handlerExceptionResolver.resolveException(request,response, null , authException); } })); http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }
3、调用登录接口(未携带Token时)
进入login接口,调用userService的login方法
1 2 3 4 5 6 7 @GetMapping("/login") public Result login (@RequestParam String username, @RequestParam String password) { User user = new User (); user.setUsername(username); user.setPassword(password); return (Result) userService.login(user); }
userService 中调用authenticationManager.authenticate()通过 UserDetailsService的loadUserByUsername()方法从数据库中读取用户信息,并封装成 UserDetails
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserMapper userMapper; @Override public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { QueryWrapper wrapper = new QueryWrapper (); wrapper.where(USER.USERNAME.eq(username)); User user = userMapper.selectOneByQuery(wrapper); if ( user == null ){ return null ; } return new LoginUser (user); } }
应该会比对加密后的密码。成功则授权。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 @Service public class UserServiceImpl extends ServiceImpl <UserMapper, User> implements UserService { @Autowired AuthenticationManager authenticationManager; @Override public Object login (User user) { UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken (user.getUsername(), user.getPassword()); BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder (); String encode = bCryptPasswordEncoder.encode(user.getPassword()); Authentication auth = authenticationManager.authenticate(authenticationToken); LoginUser loginUser = (LoginUser) auth.getPrincipal(); user = loginUser.getUser(); UserVO userVo = new UserVO (); BeanUtils.copyProperties(user, userVo); userVo.setToken(JwtUtil.generateToken(user)); return Result.success(userVo); } }
UserDetails
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 @Data @NoArgsConstructor @AllArgsConstructor public class LoginUser implements UserDetails { private User user; @Override public Collection<? extends GrantedAuthority > getAuthorities() { return null ; } @Override public String getPassword () { return user.getPassword(); } @Override public String getUsername () { return user.getUsername(); } @Override public boolean isAccountNonExpired () { return true ; } @Override public boolean isAccountNonLocked () { return true ; } @Override public boolean isCredentialsNonExpired () { return true ; } @Override public boolean isEnabled () { return true ; } }
4、进入JWTFilter(携带Token时)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 @Component public class JwtFilter extends OncePerRequestFilter { @Autowired UserMapper userMapper; @Autowired HandlerExceptionResolver handlerExceptionResolver; @Override protected void doFilterInternal ( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { String token = request.getHeader("Authorization" ); if (token == null || token.isEmpty()) { filterChain.doFilter(request, response); return ; } Integer userId = (Integer) JwtUtil.parseToken(token, "id" ); User user = userMapper.selectOneById(userId); if ( user == null ){ filterChain.doFilter(request, response); return ; } LoginUser loginUser = new LoginUser (user); UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken (loginUser, null , null ); SecurityContextHolder.getContext().setAuthentication(authenticationToken); BaseContext.setCurrentId(userId); filterChain.doFilter(request, response ); } catch (Exception e){ handlerExceptionResolver.resolveException(request, response, null , e ); } } }
5、错误处理器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @ControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(value = BadCredentialsException.class) @ResponseBody public Result loginFailed (BadCredentialsException e) { return Result.loginFail(); } @ExceptionHandler(value = InsufficientAuthenticationException.class) @ResponseBody public Result AuthenticationException (InsufficientAuthenticationException e) { return Result.loginFail(); } @ExceptionHandler(value = Exception.class) @ResponseBody public Result exception (Exception e) { return Result.error("发生错误:" +e.getMessage()); } }
二、SpringSecurity2
1、引入依赖
1 2 3 4 5 6 7 8 <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-security</artifactId > </dependency > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-oauth2</artifactId > </dependency >
2、写死用户密码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 @EnableWebSecurity @EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true) public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Bean public UserDetailsService userDetailsService () { InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager (); manager.createUser(User.withUsername("zhangsan" ).password("123" ).authorities("p1" ).build()); manager.createUser(User.withUsername("lisi" ).password("456" ).authorities("p2" ).build()); return manager; } @Bean public PasswordEncoder passwordEncoder () { return NoOpPasswordEncoder.getInstance(); } @Override protected void configure (HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/r/**" ).authenticated() .anyRequest().permitAll() .and() .formLogin().successForwardUrl("/login-success" ); } }
3、授权访问
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @RestController public class LoginController { .... @RequestMapping("/r/r1") @PreAuthorize("hasAuthority('p1')") public String r1 () { return "访问r1资源" ; } @RequestMapping("/r/r2") @PreAuthorize("hasAuthority('p2')") public String r2 () { return "访问r2资源" ; } ...
访问不属于授权的资源会报错403
用户提交用户名、密码被SecurityFilterChain中的UsernamePasswordAuthenticationFilter过滤器获取到,封装为请求Authentication,通常情况下是UsernamePasswordAuthenticationToken这个实现类。
然后过滤器将Authentication提交至认证管理器(AuthenticationManager)进行认证
认证成功后,AuthenticationManager身份管理器返回一个被填充满了信息的(包括上面提到的权限信息,身份信息,细节信息,但密码通常会被移除)Authentication实例。
SecurityContextHolder安全上下文容器将第3步填充了信息的Authentication,通过SecurityContextHolder.getContext().setAuthentication(…)方法,设置到其中。
可以看出AuthenticationManager接口(认证管理器)是认证相关的核心接口,也是发起认证的出发点,它的实现类为ProviderManager。而Spring Security支持多种认证方式,因此ProviderManager维护着一个List<AuthenticationProvider>列表,存放多种认证方式,最终实际的认证工作是由AuthenticationProvider完成的。咱们知道web表单的对应的AuthenticationProvider实现类为DaoAuthenticationProvider,它的内部又维护着一个UserDetailsService负责UserDetails的获取。最终AuthenticationProvider将UserDetails填充至Authentication。
三、OAuth2
比如微信扫码认证,是一种第三方认证方式,基于OAuth2协议实现。
基本流程:
客户端:本身不存储资源,需要通过资源拥有者的授权去请求资源服务器的资源,比如:手机客户端、浏览器等。上边示例中黑马网站即为客户端,它需要通过浏览器打开。
资源拥有者:通常为用户,也可以是应用程序,即该资源的拥有者。A表示客户端请求资源拥有者授权。B表示资源拥有者授权客户端即黑马网站访问自己的用户信息。
授权服务器(也称认证服务器)认证服务器对资源拥有者进行认证,还会对客户端进行认证并颁发令牌。C 客户端即黑马网站携带授权码请求认证。D认证通过颁发令牌。
资源服务器: 存储资源的服务器。E表示客户端即黑马网站携带令牌请求资源服务器获取资源。F表示资源服务器校验令牌通过后提供受保护资源。
1、授权码模式测试
1、配置Token
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Configuration public class TokenConfig { @Autowired TokenStore tokenStore; @Bean public TokenStore tokenStore () { return new InMemoryTokenStore (); } @Bean(name="authorizationServerTokenServicesCustom") public AuthorizationServerTokenServices tokenService () { DefaultTokenServices service=new DefaultTokenServices (); service.setSupportRefreshToken(true ); service.setTokenStore(tokenStore); service.setAccessTokenValiditySeconds(7200 ); service.setRefreshTokenValiditySeconds(259200 ); return service; } }
2、授权服务器
1)ClientDetailsServiceConfigurer :用来配置客户端详情服务(ClientDetailsService),
随便一个客户端都可以随便接入到它的认证服务吗?答案是否定的,服务提供商会给批准接入的客户端一个身份,用于接入时的凭据,有客户端标识和客户端秘钥,在这里配置批准接入的客户端的详细信息。
2)AuthorizationServerEndpointsConfigurer :用来配置令牌(token)的访问端点和令牌服务(token services)。
3)AuthorizationServerSecurityConfigurer :用来配置令牌端点的安全约束.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 @Configuration @EnableAuthorizationServer public class AuthorizationServer extends AuthorizationServerConfigurerAdapter { @Resource(name="authorizationServerTokenServicesCustom") private AuthorizationServerTokenServices authorizationServerTokenServices; @Autowired private AuthenticationManager authenticationManager; @Override public void configure (ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient("XcWebApp" ) .secret("XcWebApp" ) .resourceIds("xuecheng-plus" ) .authorizedGrantTypes("authorization_code" , "password" ,"client_credentials" ,"implicit" ,"refresh_token" ) .scopes("all" ) .autoApprove(false ) .redirectUris("http://www.51xuecheng.cn" ) ; } @Override public void configure (AuthorizationServerEndpointsConfigurer endpoints) { endpoints .authenticationManager(authenticationManager) .tokenServices(authorizationServerTokenServices) .allowedTokenEndpointRequestMethods(HttpMethod.POST); } @Override public void configure (AuthorizationServerSecurityConfigurer security) { security .tokenKeyAccess("permitAll()" ) .checkTokenAccess("permitAll()" ) .allowFormAuthenticationForClients() ; } }
3、配置认证管理Bean
1 2 3 4 5 6 7 8 9 @EnableWebSecurity @EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true) public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Bean public AuthenticationManager authenticationManagerBean () throws Exception { return super .authenticationManagerBean(); } ....
访问:
1 http://localhost:8160/auth/oauth/authorize?client_id=XcWebApp&response_type=code&scope=all&redirect_uri=http://www.51xuecheng.cn
1、在访问页面选择同意
2、请求成功,重定向至http://www.51xuecheng.cn/?code=授权码,比如:http://www.51xuecheng.cn/?code=Wqjb5H
3、使用httpclient工具post申请令牌
1 POST http://localhost:8160/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=authorization_code&code=UF4mLN&redirect_uri=http://www.51xuecheng.cn
client_id:客户端准入标识。
client_secret:客户端秘钥。
grant_type:授权类型,填写authorization_code,表示授权码模式
code:授权码,就是刚刚获取的授权码,注意:授权码只使用一次就无效了,需要重新申请。
redirect_uri:申请授权码时的跳转url,一定和申请授权码时用的redirect_uri一致。
得到结果
access_token,访问令牌,用于访问资源使用。
token_type,bearer是在RFC6750中定义的一种token类型,在携带令牌访问资源时需要在head中加入bearer 空格 令牌内容
refresh_token,当令牌快过期时使用刷新令牌可以再次生成令牌。
expires_in:过期时间(秒)
scope,令牌的权限范围,服务端可以根据令牌的权限范围去对令牌授权。
2、密码模式测试
1 POST http://localhost:8160/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=zhangsan&password=123
也可以得到授权码
3、测试生成JWT令牌
更改后的TokenConfig
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 @Configuration public class TokenConfig { private String SIGNING_KEY = "mq123" ; @Autowired private JwtAccessTokenConverter accessTokenConverter; @Bean public JwtAccessTokenConverter accessTokenConverter () { JwtAccessTokenConverter converter = new JwtAccessTokenConverter (); converter.setSigningKey(SIGNING_KEY); return converter; } @Autowired TokenStore tokenStore; @Bean public TokenStore tokenStore () { return new InMemoryTokenStore (); } @Bean(name="authorizationServerTokenServicesCustom") public AuthorizationServerTokenServices tokenService () { DefaultTokenServices service=new DefaultTokenServices (); service.setSupportRefreshToken(true ); service.setTokenStore(tokenStore); TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain (); tokenEnhancerChain.setTokenEnhancers(Arrays.asList(accessTokenConverter)); service.setTokenEnhancer(tokenEnhancerChain); service.setAccessTokenValiditySeconds(7200 ); service.setRefreshTokenValiditySeconds(259200 ); return service; } }
再次访问
1 POST http://localhost:8160/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=zhangsan&password=123
得到JWT格式令牌
access_token,生成的jwt令牌,用于访问资源使用。
token_type,bearer是在RFC6750中定义的一种token类型,在携带jwt访问资源时需要在head中加入bearer jwt令牌内容
refresh_token,当jwt令牌快过期时使用刷新令牌可以再次生成jwt令牌。
expires_in:过期时间(秒)
scope,令牌的权限范围,服务端可以根据令牌的权限范围去对令牌授权。
jti:令牌的唯一标识。
通过check_token接口校验jwt令牌
1 POST http://localhost:8160/auth/oauth/check_token?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJ6aGFuZ3NhbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE3MzkyOTU3NTQsImF1dGhvcml0aWVzIjpbInAxIl0sImp0aSI6ImIyOTVhY2FlLTVkZTgtNDM5YS05MDc0LTEwZDcwOWZlNDQ0NCIsImNsaWVudF9pZCI6IlhjV2ViQXBwIn0.w8GLMFf6T8FAKu9b9TAh0KbgJ0bfIOktvwHNLfXviec
4、携带令牌访问资源
1、引入依赖
1 2 3 4 5 6 7 8 9 <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-security</artifactId > </dependency > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-oauth2</artifactId > </dependency >
ResouceServerConfig 配置了一个资源服务器,验证TOken
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @Configuration @EnableResourceServer @EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true) public class ResouceServerConfig extends ResourceServerConfigurerAdapter { public static final String RESOURCE_ID = "xuecheng-plus" ; @Autowired TokenStore tokenStore; @Override public void configure (ResourceServerSecurityConfigurer resources) { resources.resourceId(RESOURCE_ID) .tokenStore(tokenStore) .stateless(true ); } @Override public void configure (HttpSecurity http) throws Exception { http.csrf().disable() .authorizeRequests() .anyRequest().permitAll() ; } }
TokenConfig
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Configuration public class TokenConfig { String SIGNING_KEY = "mq123" ; @Autowired private JwtAccessTokenConverter accessTokenConverter; @Bean public TokenStore tokenStore () { return new JwtTokenStore (accessTokenConverter()); } @Bean public JwtAccessTokenConverter accessTokenConverter () { JwtAccessTokenConverter converter = new JwtAccessTokenConverter (); converter.setSigningKey(SIGNING_KEY); return converter; } }
携带Token访问对应资源
1 2 GET http://localhost:63040/content/course/2 Authorization : Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJ6aGFuZ3NhbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE3MzkyOTU3NTQsImF1dGhvcml0aWVzIjpbInAxIl0sImp0aSI6ImIyOTVhY2FlLTVkZTgtNDM5YS05MDc0LTEwZDcwOWZlNDQ0NCIsImNsaWVudF9pZCI6IlhjV2ViQXBwIn0.w8GLMFf6T8FAKu9b9TAh0KbgJ0bfIOktvwHNLfXviec
四、自定义配置
1、接入数据库用户
原本我们是通过写死的UserDetail,将用户信息写在内存中:
1 2 3 4 5 6 7 8 @Bean public UserDetailsService userDetailsService () { InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager (); manager.createUser(User.withUsername("zhangsan" ).password("123" ).authorities("p1" ).build()); manager.createUser(User.withUsername("lisi" ).password("456" ).authorities("p2" ).build()); return manager; }
根据图片可知用户提交的账号密码通过
DaoAuthenticationProvider 调用
UserDetailsService的**loadUserByUsername()**方法
获取UserDetails 用户信息。 UserDetailService是一个接口,所以我们可以通过自定义UserDetailServiceImpl以及自定义loadUserByUsername()方法来获取数据库的用户信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 @Service public class UserServiceImpl implements UserDetailsService { @Autowired XcUserMapper xcUserMapper; @Override public UserDetails loadUserByUsername (String s) throws UsernameNotFoundException { XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper <XcUser>().eq(XcUser::getUsername, s)); if (user==null ){ return null ; } String password = user.getPassword(); String[] authorities= {"test" }; UserDetails userDetails = User.withUsername(user.getUsername()).password(password).authorities(authorities).build(); return userDetails; } }
2、密码加密
之前密码为明文加密,现在需要改变加密算法。添加在WebSecurityConfig 中即可
1 2 3 4 5 6 @Bean public PasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder (); }
会默认将授权服务器AuthorizationServer的客户端秘钥也进行加密验证。所以我们需要将客户端秘钥进行加密后再输入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Override public void configure (ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient("XcWebApp" ) .secret(new BCryptPasswordEncoder ().encode("XcWebApp" )) .resourceIds("xuecheng-plus" ) .authorizedGrantTypes("authorization_code" , "password" ,"client_credentials" ,"implicit" ,"refresh_token" ) .scopes("all" ) .autoApprove(false ) .redirectUris("http://www.51xuecheng.cn" ) ; }
3、扩展用户身份信息
用户表中存储了用户的账号、手机号、email,昵称、qq等信息,UserDetails接口(JWT)只返回了username、密码等信息。
也就是说我们通过SPringSecurity只能得到用户名和密码,得不到其他信息。所以我们需要拓展信息。
两种方式:
1、UserDetails中自定义username、password。所以我们可以拓展UserDetail
2、拓展username的内容,将包含所需信息的Json串存入username。这样就不需要修改UserDetail的结构。
采取第二种方式。修改UserServiceImpl:
添加将user转为json这个操作,存入userDetails对象的username属性中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 @Service public class UserServiceImpl implements UserDetailsService { @Autowired XcUserMapper xcUserMapper; @Override public UserDetails loadUserByUsername (String s) throws UsernameNotFoundException { XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper <XcUser>().eq(XcUser::getUsername, s)); if (user==null ){ return null ; } String password = user.getPassword(); String[] authorities = {"p1" }; user.setPassword(null ); String userString = JSON.toJSONString(user); UserDetails userDetails = User.withUsername(userString).password(password).authorities(authorities).build(); return userDetails; } }
这样当资源服务得到username时,只需要解析Json串即可得到想要的信息。
4、资源服务获取身份
SecurityUtil工具类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 @Slf4j public class SecurityUtil { public static XcUser getUser () { try { Object principalObj = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); if (principalObj instanceof String) { String principal = principalObj.toString(); XcUser user = JSON.parseObject(principal, XcUser.class); return user; } } catch (Exception e) { log.error("获取当前登录用户身份出错:{}" , e.getMessage()); e.printStackTrace(); } return null ; } @Data public static class XcUser implements Serializable { private static final long serialVersionUID = 1L ; private String id; private String username; private String password; private String salt; private String name; private String nickname; private String wxUnionid; private String companyId; private String userpic; private String utype; private LocalDateTime birthday; private String sex; private String email; private String cellphone; private String qq; private String status; private LocalDateTime createTime; private LocalDateTime updateTime; } }
5、统一认证路口
登录应该支持多种登录方式:
1、支持账号和密码认证:采用OAuth2协议的密码模式即可实现。
2、支持手机号加验证码认证:用户认证提交的是手机号和验证码,并不是账号和密码。
3、微信扫码认证:基于OAuth2协议与微信交互,学成在线网站向微信服务器申请到一个令牌,然后携带令牌去微信查询用户信息,查询成功则用户在学成在线项目认证通过。
之前我们通过自定义的UserDetailsService的loadUserByUsername()方法获取UserDetails用户信息。而我们不同的登录方式需要不同的处理。
所以我们可以修改loadUserByUsername(String s )的传参用户名s格式。 使其变成能接受多种属性,支持多种登录的状态。
将用户原来提交的账号数据改为提交json数据,json数据可以扩展不同认证方式所提交的各种参数。
1 2 3 4 5 6 7 8 9 10 11 @Data public class AuthParamsDto { private String username; private String password; private String cellphone; private String checkcode; private String checkcodekey; private String authType; private Map<String, Object> payload = new HashMap <>(); }
修改loadUserByUsername
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 @Slf4j @Service public class UserServiceImpl implements UserDetailsService { @Autowired XcUserMapper xcUserMapper; @Override public UserDetails loadUserByUsername (String s) throws UsernameNotFoundException { AuthParamsDto authParamsDto = null ; try { authParamsDto = JSON.parseObject(s, AuthParamsDto.class); } catch (Exception e) { log.info("认证请求不符合项目要求:{}" ,s); throw new RuntimeException ("认证请求数据格式不对" ); } String username = authParamsDto.getUsername(); XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper <XcUser>().eq(XcUser::getUsername, username)); if (user==null ){ return null ; } String password = user.getPassword(); String[] authorities = {"p1" }; String userString = JSON.toJSONString(user); UserDetails userDetails = User.withUsername(userString).password(password).authorities(authorities).build(); return userDetails; } }
原来的DaoAuthenticationProvider 会进行密码校验,我们重新定义DaoAuthenticationProviderCustom类,重写类的additionalAuthenticationChecks方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Slf4j @Component public class DaoAuthenticationProviderCustom extends DaoAuthenticationProvider { @Autowired public void setUserDetailsService (UserDetailsService userDetailsService) { super .setUserDetailsService(userDetailsService); } protected void additionalAuthenticationChecks (UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { } }
修改WebSecurityConfig类指定daoAuthenticationProviderCustom
1 2 3 4 5 6 7 8 @Autowired DaoAuthenticationProviderCustom daoAuthenticationProviderCustom; @Override protected void configure (AuthenticationManagerBuilder auth) throws Exception { auth.authenticationProvider(daoAuthenticationProviderCustom); }
例如这样使用新的方式:
1 2 3 ################扩展认证请求参数后###################### ###密码模式 POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username={"username":"stu1","authType":"password","password":"111111"}
6、支持多种认证方式
前面我们统一的认证路口,接下来要对应不同的登录方式编写不同的代码
定义用户信息,为了扩展性让它继承XcUser
1 2 3 @Data public class XcUserExt extends XcUser {}
定义认证Service接口,用于授权登录,供多种登录方式继承。通过不同的AuthServiceImpl、不同的execute()实现多种登录方式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 package com.xuecheng.ucenter.service;import com.xuecheng.ucenter.model.dto.AuthParamsDto;import com.xuecheng.ucenter.model.po.XcUser;public interface AuthService { XcUserExt execute (AuthParamsDto authParamsDto) ; }
修改loadUserByUsername()如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 @Autowired ApplicationContext applicationContext; @Override public UserDetails loadUserByUsername (String s) throws UsernameNotFoundException { AuthParamsDto authParamsDto = null ; try { authParamsDto = JSON.parseObject(s, AuthParamsDto.class); } catch (Exception e) { log.info("认证请求不符合项目要求:{}" ,s); throw new RuntimeException ("认证请求数据格式不对" ); } authService.execute(authParamsDto); ...
到目前为止,登录流程修改如上
7、账号密码自定义登录
password_authservice是为该服务类定义的 Bean 名称
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 @Service("password_authservice") public class PasswordAuthServiceImpl implements AuthService { @Autowired XcUserMapper xcUserMapper; @Autowired PasswordEncoder passwordEncoder; @Override public XcUserExt execute (AuthParamsDto authParamsDto) { String username = authParamsDto.getUsername(); XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper <XcUser>().eq(XcUser::getUsername, username)); if (user==null ){ throw new RuntimeException ("账号不存在" ); } XcUserExt xcUserExt = new XcUserExt (); BeanUtils.copyProperties(user,xcUserExt); String passwordDb = user.getPassword(); String passwordForm = authParamsDto.getPassword(); boolean matches = passwordEncoder.matches(passwordForm, passwordDb); if (!matches){ throw new RuntimeException ("账号或密码错误" ); } return xcUserExt; } }
修改UserServiceImpl类的loadUserByUsername(),根据认证方式使用不同的认证bean
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 @Slf4j @Service public class UserServiceImpl implements UserDetailsService { @Autowired XcUserMapper xcUserMapper; @Autowired ApplicationContext applicationContext; @Override public UserDetails loadUserByUsername (String s) throws UsernameNotFoundException { AuthParamsDto authParamsDto = null ; try { authParamsDto = JSON.parseObject(s, AuthParamsDto.class); } catch (Exception e) { log.info("认证请求不符合项目要求:{}" ,s); throw new RuntimeException ("认证请求数据格式不对" ); } String authType = authParamsDto.getAuthType(); AuthService authService = applicationContext.getBean(authType + "_authservice" ,AuthService.class); XcUserExt user = authService.execute(authParamsDto); return getUserPrincipal(user); } public UserDetails getUserPrincipal (XcUserExt user) { String[] authorities = {"p1" }; String password = user.getPassword(); user.setPassword(null ); String userString = JSON.toJSONString(user); UserDetails userDetails = User.withUsername(userString).password(password ).authorities(authorities).build(); return userDetails; } }
8、部署验证码
自备学成在线验证码服务,需要配置好nacos、redis。
也可以采用在线的验证码服务。
验证码服务一般对外提供2个接口: 1、生成验证码 2、检验验证码
验证码服务如何生成并校验验证码?
拿图片验证码举例:
1、先生成一个指定位数的验证码,根据需要可能是数字、数字字母组合或文字。
2、根据生成的验证码生成一个图片并返回给页面
3、给生成的验证码分配一个key,将key和验证码一同存入缓存。这个key和图片一同返回给页面。
4、用户输入验证码,连同key一同提交至认证服务。
5、认证服务拿key和输入的验证码请求验证码服务去校验
6、验证码服务根据key从缓存取出正确的验证码和用户输入的验证码进行比对,如果相同则校验通过,否则不通过。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Api(value = "验证码服务接口") @RestController public class CheckCodeController { @ApiOperation(value="生成验证信息", notes="生成验证信息") @PostMapping(value = "/pic") public CheckCodeResultDto generatePicCheckCode (CheckCodeParamsDto checkCodeParamsDto) { } @ApiOperation(value="校验", notes="校验") @ApiImplicitParams({ @ApiImplicitParam(name = "name", value = "业务名称", required = true, dataType = "String", paramType="query"), @ApiImplicitParam(name = "key", value = "验证key", required = true, dataType = "String", paramType="query"), @ApiImplicitParam(name = "code", value = "验证码", required = true, dataType = "String", paramType="query") }) @PostMapping(value = "/verify") public Boolean verify (String key, String code) { } }
1、生成验证码接口
1 2 ### 申请验证码 POST {{checkcode_host}}/checkcode/pic
2、校验验证码接口
根据生成验证码返回的key以及日志中输出正确的验证码去测试。
1 2 POST {{checkcode_host}}/checkcode/verify?key=checkcode4506b95bddbe46cdb0d56810b747db1b&code=70dl
同时开发远程调用验证码服务的接口CheckCodeClient
完善后的接口实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 @Service("password_authservice") public class PasswordAuthServiceImpl implements AuthService { @Autowired XcUserMapper xcUserMapper; @Autowired PasswordEncoder passwordEncoder; @Autowired CheckCodeClient checkCodeClient; @Override public XcUser execute (AuthParamsDto authParamsDto) { String checkcode = authParamsDto.getCheckcode(); String checkcodekey = authParamsDto.getCheckcodekey(); if (StringUtils.isBlank(checkcodekey) || StringUtils.isBlank(checkcode)){ throw new RuntimeException ("验证码为空" ); } Boolean verify = checkCodeClient.verify(checkcodekey, checkcode); if (!verify){ throw new RuntimeException ("验证码输入错误" ); } String username = authParamsDto.getUsername(); XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper <XcUser>().eq(XcUser::getUsername, username)); if (user==null ){ throw new RuntimeException ("账号不存在" ); } String passwordDb = user.getPassword(); String passwordForm = authParamsDto.getPassword(); boolean matches = passwordEncoder.matches(passwordForm, passwordDb); if (!matches){ throw new RuntimeException ("账号或密码错误" ); } return user; } }
9、微信扫码登录
下列简单总结一下微信扫码登录的要点:
微信扫码登录基于OAuth2协议的授权码模式
1、请求获取授权码(得到code和state)
需要用户通过微信登录时,网站会跳转到如下网址,出现一个微信登录二维码
1 https://open.weixin.qq.com/connect/qrconnect?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=SCOPE&state=STATE#wechat_redirect
用户允许授权后,将会重定向到redirect_uri的网址上,并且带上 code 和state参数
1 redirect_uri?code=CODE&state=STATE
还有一种方式就是内嵌二维码
引入js
1 http://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js
定义js对象
1 2 3 4 5 6 7 8 9 10 var obj = new WxLogin({ self_redirect:true, id:"login_container", appid: "", scope: "", redirect_uri: "", state: "", style: "", href: "" });
2、通过code获取access_token
1 https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code
appid:应用唯一标识,在微信开放平台提交应用审核通过后获得
secret:应用密钥AppSecret,在微信开放平台提交应用审核通过后获得
code:填写第一步获取的 code 参数
grant type:此处固定填写authorization_code
正确的返回:
1 2 3 4 5 6 7 8 { "access_token":"ACCESS_TOKEN", "expires_in":7200, "refresh_token":"REFRESH_TOKEN", "openid":"OPENID", "scope":"SCOPE", "unionid": "o6_bmasdasdsad6_2sgVt7hMZOPfL" }
access_token:接口调用凭证
expires_in:access_token接口调用凭证超时时间,单位(秒)
refresh_token:用户刷新access_token
openid:用户唯一标识
scope:用户授权的作用域,使用逗号分隔
unionid:当且仅当该网站应用已获得该用户的userinfo授权时,才会出现该字段
3、通过access_token调用接口
接口作用域,可以调用如下接口
授权作用域(scope)
命令/接口
接口说明
snsapi_base
/sns/oauth2/access_token
通过 code 换取 access_token/refresh_toekn和已授权scope
snsapi_base
/sns/oauth2/refresh_token
刷新或续期 access_token
snsapi_base
/sns/auth
检查 access_token 有效性
snsapi_userinfo
/sns/userinfo
获取用户个人信息
获取用户信息接口:
1 2 http请求方式: GET https://api.weixin.qq.com/sns/userinfo?access_token=ACCESS_TOKEN&openid=OPENID
响应格式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 { "openid" :"OPENID" , "nickname" :"NICKNAME" , "sex" :1 , "province" :"PROVINCE" , "city" :"CITY" , "country" :"COUNTRY" , "headimgurl" : "https://thirdwx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/0" , "privilege" :[ "PRIVILEGE1" , "PRIVILEGE2" ], "unionid" : " o6_bmasdasdsad6_2sgVt7hMZOPfL" }
参数
说明
openid
普通用户的标识,对当前开发者帐号唯一
nickname
普通用户昵称
sex
普通用户性别,1为男性,2为女性
province
普通用户个人资料填写的省份
city
普通用户个人资料填写的城市
country
国家,如中国为CN
headimgurl
用户头像,最后一个数值代表正方形头像大小(有0、46、64、96、132数值可选,0代表640*640正方形头像),用户没有头像时该项为空
privilege
用户特权信息,json数组,如微信沃卡用户为(chinaunicom)
unionid
用户统一标识。针对一个微信开放平台帐号下的应用,同一用户的unionid是唯一的。
4、准备开发环境
1、添加应用
注册微信开放平台https://open.weixin.qq.com/
添加应用
添加应用需要指定一个外网域名作为微信回调域名、审核通过后,生成app密钥。最终获取appID和AppSecret
2、内网穿透
本项目认证服务需要做哪些事?
1、需要定义接口接收微信下发的授权码。
2、收到授权码调用微信接口申请令牌。
3、申请到令牌调用微信获取用户信息
4、获取用户信息成功将其写入本项目用户中心数据库。
5、最后重定向到浏览器自动登录。
WxLoginController类,接收微信下发的授权码(code)接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Slf4j @Controller public class WxLoginController { @RequestMapping("/wxLogin") public String wxLogin (String code, String state) throws IOException { log.debug("微信扫码回调,code:{},state:{}" ,code,state); XcUser xcUser = new XcUser (); xcUser.setUsername("t1" ); if (xcUser==null ){ return "redirect:http://www.51xuecheng.cn/error.html" ; } String username = xcUser.getUsername(); return "redirect:http://www.51xuecheng.cn/sign.html?username=" +username+"&authType=wx" ; } }
定义微信认证的service
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @Slf4j @Service("wx_authservice") public class WxAuthServiceImpl implements AuthService { @Autowired XcUserMapper xcUserMapper; @Override public XcUserExt execute (AuthParamsDto authParamsDto) { String username = authParamsDto.getUsername(); XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper <XcUser>().eq(XcUser::getUsername, username)); if (user==null ){ throw new RuntimeException ("账号不存在" ); } XcUserExt xcUserExt = new XcUserExt (); BeanUtils.copyProperties(user,xcUserExt); return xcUserExt; } }
5、接入微信认证
1、使用restTemplate请求微信,配置RestTemplate bean
在启动类配置restTemplate
1 2 3 4 5 @Bean RestTemplate restTemplate () { RestTemplate restTemplate = new RestTemplate (new OkHttp3ClientHttpRequestFactory ()); return restTemplate; }
2、定义与微信认证的service接口:
注意:这只是供微信认证调用的方法
1 2 3 4 5 public interface WxAuthService { public XcUser wxAuth (String code) ; }
3、下边在controller中调用wxAuth接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Slf4j @Controller public class WxLoginController { @Autowired WxAuthService wxAuthService; @RequestMapping("/wxLogin") public String wxLogin (String code, String state) throws IOException { log.debug("微信扫码回调,code:{},state:{}" ,code,state); XcUser xcUser = wxAuthService.wxAuth(code); if (xcUser==null ){ return "redirect:http://www.51xuecheng.cn/error.html" ; } String username = xcUser.getUsername(); return "redirect:http://www.51xuecheng.cn/sign.html?username=" +username+"&authType=wx" ; } }
4、在WxAuthService 的wxAuth方法中实现申请令牌、查询用户信息等内容。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 @Slf4j @Service("wx_authservice") public class WxAuthServiceImpl implements AuthService , WxAuthService {@Autowired XcUserMapper xcUserMapper; @Autowired RestTemplate restTemplate; @Value("${weixin.appid}") String appid; @Value("${weixin.secret}") String secret; public XcUser wxAuth (String code) { Map<String, String> access_token_map = getAccess_token(code); if (access_token_map==null ){ return null ; } System.out.println(access_token_map); String openid = access_token_map.get("openid" ); String access_token = access_token_map.get("access_token" ); Map<String, String> userinfo = getUserinfo(access_token, openid); if (userinfo==null ){ return null ; } XcUser xcUser = null ; return xcUser; } private Map<String,String> getAccess_token (String code) { String wxUrl_template = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code" ; String wxUrl = String.format(wxUrl_template, appid, secret, code); log.info("调用微信接口申请access_token, url:{}" , wxUrl); ResponseEntity<String> exchange = restTemplate.exchange(wxUrl, HttpMethod.POST, null , String.class); String result = exchange.getBody(); log.info("调用微信接口申请access_token: 返回值:{}" , result); Map<String,String> resultMap = JSON.parseObject(result, Map.class); return resultMap; } private Map<String,String> getUserinfo (String access_token,String openid) { String wxUrl_template = "https://api.weixin.qq.com/sns/userinfo?access_token=%s&openid=%s" ; String wxUrl = String.format(wxUrl_template, access_token,openid); log.info("调用微信接口申请access_token, url:{}" , wxUrl); ResponseEntity<String> exchange = restTemplate.exchange(wxUrl, HttpMethod.POST, null , String.class); String result = new String (exchange.getBody().getBytes(StandardCharsets.ISO_8859_1),StandardCharsets.UTF_8); log.info("调用微信接口申请access_token: 返回值:{}" , result); Map<String,String> resultMap = JSON.parseObject(result, Map.class); return resultMap; } ....
5、向数据库保存用户信息
向WxAuthServiceImpl添加
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 @Autowired XcUserRoleMapper xcUserRoleMapper; @Transactional public XcUser addWxUser (Map userInfo_map) { String unionid = userInfo_map.get("unionid" ).toString(); XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper <XcUser>().eq(XcUser::getWxUnionid, unionid)); if (xcUser!=null ){ return xcUser; } String userId = UUID.randomUUID().toString(); xcUser = new XcUser (); xcUser.setId(userId); xcUser.setWxUnionid(unionid); xcUser.setNickname(userInfo_map.get("nickname" ).toString()); xcUser.setUserpic(userInfo_map.get("headimgurl" ).toString()); xcUser.setName(userInfo_map.get("nickname" ).toString()); xcUser.setUsername(unionid); xcUser.setPassword(unionid); xcUser.setUtype("101001" ); xcUser.setStatus("1" ); xcUser.setCreateTime(LocalDateTime.now()); xcUserMapper.insert(xcUser); XcUserRole xcUserRole = new XcUserRole (); xcUserRole.setId(UUID.randomUUID().toString()); xcUserRole.setUserId(userId); xcUserRole.setRoleId("17" ); xcUserRoleMapper.insert(xcUserRole); return xcUser; }
并修改,调用保存用户信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @Autowired WxAuthServiceImpl currentProxy; public XcUser wxAuth (String code) { Map<String, String> access_token_map = getAccess_token(code); if (access_token_map==null ){ return null ; } System.out.println(access_token_map); String openid = access_token_map.get("openid" ); String access_token = access_token_map.get("access_token" ); Map<String, String> userinfo = getUserinfo(access_token, openid); if (userinfo==null ){ return null ; } XcUser xcUser = currentProxy.addWxUser(userinfo); return xcUser; }
五、RABC
RBAC分为两种方式:
基于角色的访问控制(Role-Based Access Control)
按照角色进行授权,例如班主任可以查询全部学生的成绩,信息等。
1 2 3 if (主体.hasRole("总经理角色id" ) || 主体.hasRole("部门经理角色id" )){ 查询工资 }
缺点很明显,如果某个方法多种角色都可以操作,那么就需要判断很多角色,系统扩展性差
基于资源的访问控制(Resource-Based Access Control)
判断主体有无此资源的访问权限
1 2 3 if (主体.hasPermission("查询工资权限标识" )){ 查询工资 }
优点:系统设计时定义好查询工资的权限标识,即使查询工资所需要的角色变化为总经理和部门经理也不需要修改授权代码,系统可扩展性强。
1、资源服务集成Spring Security
在需要授权的接口处使用@PreAuthorize(“hasAuthority(‘权限标识符’)”)进行控制
下边代码指定/course/list接口需要拥有xc_teachmanager_course_list 权限。
2、在统一异常处理处解析此异常信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @ResponseBody @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public RestErrorResponse exception (Exception e) { log.error("【系统异常】{}" ,e.getMessage(),e); e.printStackTrace(); if (e.getMessage().equals("不允许访问" )){ return new RestErrorResponse ("没有操作此功能的权限" ); } return new RestErrorResponse (CommonError.UNKOWN_ERROR.getErrMessage()); }
3、网页访问调用对应接口提示没有权限
学习一下权限控制数据模型
xc_user:用户表,存储了系统用户信息,用户类型包括:学生、老师、管理员等
xc_role:角色表,存储了系统的角色信息,学生、老师、教学管理员、系统管理员等。
xc_user_role:用户角色表,一个用户可拥有多个角色,一个角色可被多个用户所拥有
xc_menu:模块表,记录了菜单及菜单下的权限
xc_permission:角色权限表,一个角色可拥有多个权限,一个权限可被多个角色所拥有
对应添加权限的操作不做细究
Mapper接口
1 2 3 4 public interface XcMenuMapper extends BaseMapper <XcMenu> { @Select("SELECT * FROM xc_menu WHERE id IN (SELECT menu_id FROM xc_permission WHERE role_id IN ( SELECT role_id FROM xc_user_role WHERE user_id = #{userId} ))") List<XcMenu> selectPermissionByUserId (@Param("userId") String userId) ; }
查询用户所拥有的角色 -> 根据角色查询权限表得到对应的模块id -> 得到所有菜单以及子菜单中的权限
修改UserServiceImpl类的getUserPrincipal方法,查询权限信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public UserDetails getUserPrincipal (XcUserExt user) { String password = user.getPassword(); List<XcMenu> xcMenus = menuMapper.selectPermissionByUserId(user.getId()); List<String> permissions = new ArrayList <>(); if (xcMenus.size()<=0 ){ permissions.add("p1" ); }else { xcMenus.forEach(menu->{ permissions.add(menu.getCode()); }); } user.setPermissions(permissions); user.setPassword(null ); String userString = JSON.toJSONString(user); String[] authorities = permissions.toArray(new String [0 ]); UserDetails userDetails = User.withUsername(userString).password(password).authorities(authorities).build(); return userDetails; }
4、细粒度授权
用户A和用户B都是教学机构,他们都拥有“我的课程”权限,但是两个用户所查询到的数据是不一样的。
实现细粒度授权可以用业务逻辑实现,比如
1 2 3 4 5 6 7 8 9 10 11 教学机构在维护课程时只允许维护本机构的课程,教学机构细粒度授权过程如下: 1)获取当前登录的用户身份 2)得到用户所属教育机构的Id 3)查询该教学机构下的课程信息 最终实现了用户只允许查询自己机构的课程信息。 根据公司Id查询课程,流程如下: 1)教学机构用户登录系统,从用户身份中取出所属机构的id 在用户表中设计了company_id字段存储该用户所属的机构id. 2)接口层取出当前登录用户的身份,取出机构id 3) 将机构id传入service方法。 4) service方法将机构id传入Dao方法,最终查询出本机构的课程信息。
代码实现如下:
1 2 3 4 5 6 7 8 9 10 @ApiOperation("课程查询接口") @PreAuthorize("hasAuthority('xc_teachmanager_course_list')") @PostMapping("/course/list") public PageResult<CourseBase> list (PageParams pageParams, @RequestBody QueryCourseParamsDto queryCourseParams) { XcUser user = SecurityUtil.getUser(); String companyId = user.getCompanyId(); return courseBaseInfoService.queryCourseBaseList(Long.parseLong(companyId),pageParams,queryCourseParams); }
Service方法如下:
1 2 3 4 5 6 7 8 @Override public PageResult<CourseBase> queryCourseBaseList (Long companyId,PageParams pageParams, QueryCourseParamsDto queryCourseParamsDto) { LambdaQueryWrapper<CourseBase> queryWrapper = new LambdaQueryWrapper <>(); queryWrapper.eq(CourseBase::getCompanyId,companyId); ....
六、支付接入
我们这里采用支付宝沙箱 模拟接入支付
1、前置操作
最好有一个通用订单服务,能够处理订单生成、支付、退款、接收支付通知等操作。
比如学成在线选课时就需要支付课程费用,我们这里就用到了订单表,定义了一些相关字段。
2、配置沙箱环境
1、进入支付宝开放平台沙箱小程序
沙箱应用 - 开放平台 (alipay.com)
申请沙箱应用,使用公钥模式,看到APPID等信息
点击查看公钥,可以看到应用公钥 、应用私钥 、支付宝公钥 等信息
点击沙箱账号,可以进行虚拟充值
2、手机或模拟器安装支付宝沙箱版
沙箱工具 - 开放平台 (alipay.com)
3、接口介绍与使用
1)用户在商户的H5网站下单支付后,商户系统按照手机网站支付接口alipay.trade.wap.pay API的参数规范生成订单数据
2)前端页面通过Form表单的形式请求到支付宝。此时支付宝会自动将页面跳转至支付宝H5收银台页面,如果用户手机上安装了支付宝APP,则自动唤起支付宝APP。
3)输入支付密码完成支付。
4)用户在支付宝APP或H5收银台完成支付后,会根据商户在手机网站支付API中传入的前台回跳地址return_url自动跳转回商户页面,同时在URL请求中以Query String的形式附带上支付结果参数,详细回跳参数见“手机网站支付接口alipay.trade.wap.pay”前台回跳参数 。
5)支付宝还会根据原始支付API中传入的异步通知地址notify_url,通过POST请求的形式将支付结果作为参数通知到商户系统,详情见支付结果异步通知 。
1、外部商户请求支付宝创建订单并支付
请求沙箱地址:https://openapi.alipaydev.com/gateway.do
请求参数查阅:https://opendocs.alipay.com/open/203/107090
引入依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 <dependency > <groupId > com.alipay.sdk</groupId > <artifactId > alipay-sdk-java</artifactId > <version > 3.7.73.ALL</version > </dependency > <dependency > <groupId > commons-logging</groupId > <artifactId > commons-logging</artifactId > <version > 1.2</version > </dependency >
创建订单接口,当然需要在配置文件中配置好APP_ID 、APP_PRIVATE_KEY 、ALIPAY_PUBLIC_KEY (这里配置到了nacos中),用户扫描支付二维码 进入这个接口,会自动调用支付宝 完成支付。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 @Controller public class PayTestController { @Value("${pay.alipay.APP_ID}") String APP_ID; @Value("${pay.alipay.APP_PRIVATE_KEY}") String APP_PRIVATE_KEY; @Value("${pay.alipay.ALIPAY_PUBLIC_KEY}") String ALIPAY_PUBLIC_KEY; @RequestMapping("/alipaytest") public void doPost (HttpServletRequest httpRequest, HttpServletResponse httpResponse) throws ServletException, IOException, AlipayApiException { AlipayClient alipayClient = new DefaultAlipayClient (AlipayConfig.URL, APP_ID, APP_PRIVATE_KEY, AlipayConfig.FORMAT, AlipayConfig.CHARSET, ALIPAY_PUBLIC_KEY,AlipayConfig.SIGNTYPE); AlipayTradeWapPayRequest alipayRequest = new AlipayTradeWapPayRequest (); alipayRequest.setNotifyUrl("http://nxft58.natappfree.cc/orders/paynotify" ); alipayRequest.setBizContent("{" + " \"out_trade_no\":\"103410990810107895\"," + " \"total_amount\":12003," + " \"subject\":\"iphone16 100TB\"," + " \"product_code\":\"QUICK_WAP_WAY\"" + " }" ); String form = alipayClient.pageExecute(alipayRequest).getBody(); httpResponse.setContentType("text/html;charset=" + AlipayConfig.CHARSET); httpResponse.getWriter().write(form); httpResponse.getWriter().flush(); } }
2、支付结果查询接口
通过我们刚刚的out_trade_no号来查询订单支付情况
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @Test public void queryPayResult () throws AlipayApiException { AlipayClient alipayClient = new DefaultAlipayClient (AlipayConfig.URL, APP_ID, APP_PRIVATE_KEY, "json" , AlipayConfig.CHARSET, ALIPAY_PUBLIC_KEY, AlipayConfig.SIGNTYPE); AlipayTradeQueryRequest request = new AlipayTradeQueryRequest (); JSONObject bizContent = new JSONObject (); bizContent.put("out_trade_no" , "1846935012016668672" ); request.setBizContent(bizContent.toString()); AlipayTradeQueryResponse response = alipayClient.execute(request); if (response.isSuccess()) { System.out.println("调用成功" ); String resultJson = response.getBody(); Map resultMap = JSON.parseObject(resultJson, Map.class); Map alipay_trade_query_response = (Map) resultMap.get("alipay_trade_query_response" ); String trade_status = (String) alipay_trade_query_response.get("trade_status" ); System.out.println(trade_status); } else { System.out.println("调用失败" ); } }
返回结果调用成功!
1 @SpringBootTest(classes = Test.class)
测试类注解加上这句,就不会运行一整个项目
3、支付结果通知接口
根据下单执行流程,订单服务收到支付结果需要对内容进行验签,验签过程如下:
在通知返回参数列表中,除去sign、sign_type两个参数外,凡是通知返回回来的参数皆是待验签的参数。将剩下参数进行 url_decode,然后进行字典排序,组成字符串,得到待签名字符串; 生活号异步通知组成的待验签串里需要保留 sign_type 参数。
将签名参数(sign)使用 base64 解码为字节码串;
使用 RSA 的验签方法,通过签名字符串、签名参数(经过 base64 解码)及支付宝公钥验证签名。
验证签名正确后,必须再严格按照如下描述校验通知数据的正确性。
在上述验证通过后,商户必须根据支付宝不同类型的业务通知,正确的进行不同的业务处理,并且过滤重复的通知结果数据。
通过验证out_trade_no、total_amount、appid参数的正确性判断通知请求的合法性。
验证的过程可以参考sdk demo代码,下载 sdk demo代码,https://opendocs.alipay.com/open/203/105910
1、下单时设置通知地址
1 2 3 4 5 6 7 8 9 10 @GetMapping("/alipaytest") public void alipaytest (HttpServletRequest httpRequest, HttpServletResponse httpResponse) throws ServletException, IOException { AlipayClient alipayClient = new DefaultAlipayClient (serverUrl, APP_ID, APP_PRIVATE_KEY, "json" , CHARSET, ALIPAY_PUBLIC_KEY, sign_type); AlipayTradeWapPayRequest alipayRequest = new AlipayTradeWapPayRequest (); alipayRequest.setNotifyUrl("http://tjxt-user-t.itheima.net/xuecheng/orders/paynotify" ); .....
2、编写接收通知接口,接收参数并验签
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 @PostMapping("/paynotify") public void paynotify (HttpServletRequest request,HttpServletResponse response) throws UnsupportedEncodingException, AlipayApiException { Map<String,String> params = new HashMap <String,String>(); Map requestParams = request.getParameterMap(); for (Iterator iter = requestParams.keySet().iterator(); iter.hasNext();) { String name = (String) iter.next(); String[] values = (String[]) requestParams.get(name); String valueStr = "" ; for (int i = 0 ; i < values.length; i++) { valueStr = (i == values.length - 1 ) ? valueStr + values[i] : valueStr + values[i] + "," ; } params.put(name, valueStr); } boolean verify_result = AlipaySignature.rsaCheckV1(params, ALIPAY_PUBLIC_KEY, AlipayConfig.CHARSET, "RSA2" ); if (verify_result) { String out_trade_no = new String (request.getParameter("out_trade_no" ).getBytes("ISO-8859-1" ),"UTF-8" ); String trade_no = new String (request.getParameter("trade_no" ).getBytes("ISO-8859-1" ),"UTF-8" ); String trade_status = new String (request.getParameter("trade_status" ).getBytes("ISO-8859-1" ),"UTF-8" ); if (trade_status.equals("TRADE_FINISHED" )) { } else if (trade_status.equals("TRADE_SUCCESS" )) { System.out.println(trade_status); } response.getWriter().write("success" ); }else { response.getWriter().write("fail" ); } }
4、端口映射
这里采用NATAPP-内网穿透 基于ngrok的国内高速内网映射工具
注册登录后,添加免费WEB隧道,下载客户端,配置config.ini中的authtoken
双击natapp.exe打开即可
5、生成二维码
引入依赖
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <dependency > <groupId > com.google.zxing</groupId > <artifactId > core</artifactId > <version > 3.3.3</version > </dependency > <dependency > <groupId > com.google.zxing</groupId > <artifactId > javase</artifactId > <version > 3.3.3</version > </dependency > <dependency > <groupId > org.apache.commons</groupId > <artifactId > commons-lang3</artifactId > </dependency >
生成代码,这里的地址我们通过端口映射------>localhost:63030
1 2 3 4 public static void main (String[] args) throws IOException { QRCodeUtil qrCodeUtil = new QRCodeUtil (); System.out.println(qrCodeUtil.createQRCode("http://3ndq55.natappfree.cc/orders/alipaytest" , 200 , 200 )); }
得到一个base64串,浏览器输入可以得到支付二维码,这个二维码会访问我们上面的创建订单接口
1 data:image/png;base64 ,iVBORw0KGgoAAAANSUhEUgAAAMgAAADIAQAAAACFI5MzAAABQElEQVR42u2YPZKDMAyF5aFIuUfIUThafDSOwhEoUzC8fZKMySSbrVI8ZuICBX8uIvtZPxjeDfuSf8liPi7LFSgrzRTvV3XCKawXYLptFobviz6ZzB2xEfTjhyS9OwXB3A7jbMSngLOQ0I4v2AZf96wqTWJ9+9/dYEHSx2RYqfg/oqUgiX3nFBVfcCepcSbiJP67iwZ1G+5+Am7kyTzW9OcW/kRAX+QJ953+uCl8zO5PV5UsaffUp8rqP5+jiySJU8jtNxcNrysetCNK6A/V4lEQeU+xa0eZREE1tOTpFYod0VKXsKCqvRqMkW5pkza8Ggy3WgEuTvZcz0dcUBc+9MneL1DqkXjQz0eaZA1LqVtmzcMffTKPiPwz1mh2zkGyNwtT9kguTVI7LWv6ul7DCpOjX9iaGV66HDny/ZL1WfILfc/hMHLUpekAAAAASUVORK5CYII=
通过支付宝沙箱版app扫码即可支付。
6、业务代码简略
核心代码再接口介绍与使用,这里不介绍具体的业务代码,没必要,根据自身业务逻辑再去编写。
订单支付模式的核心由三张表组成:订单表、订单明细表、支付记录表。
支付记录表存在的意义:
如果我们直接将订单表订单号传入第三方支付平台,当第三方支付平台此订单支付交易关闭的时候,再想支付相同订单就很难了。所以我们引入支付记录表,记录订单id,并且生成唯一的out_pay_no。可以解决问题。
解决以上问题的方案是:
1、用户每次发起支付都创建一个新的支付记录 ,此支付记录与商品订单关联。
2、将支付记录的流水号传给第三方支付系统的下单接口,这样即可解决上边的问题。
3、不过在程序中要考虑重复支付的问题。
订单号注意唯一性、安全性、尽量短等特点,生成方案常用的如下:
方案
描述
时间戳+随机数
年月日时分秒毫秒 + 随机数
高并发场景
年月日时分秒毫秒 + 随机数 + Redis 自增序列
订单号中加上业务标识
订单号加上业务标识方便客服,比如:第 10 位是业务类型,第 11 位是用户类型等。
雪花算法
雪花算法是推特内部使用的分布式环境下的唯一 ID 生成算法,基于时间戳生成,保证有序递增,加上计算机硬件等元素,满足高并发环境下 ID 不重复。
雪花算法:
1 2 long payNo = IdWorkerUtils.getInstance().nextId();
重复支付问题:
上边实现中,扫码下单时会根据支付记录号判断是否支付完成,生成二维码时也会判断订单的状态是否支付完成,如果支付完成将不再重新支付,即使这样做也无法绝对避免重复支付。
正常情况下同一个用户的同一个订单不会存在并发扫码支付的问题,但系统是可能存在重复支付的问题的,解决该问题需要单独定义任务,每隔24小时查询前一天的订单是否存在重复支付的问题,如果存在则调用第三方支付平台的退款接口进行退款。