SpringSecurity认证授权

SpringSecurity认证授权

一、SpringSecurity3

1、引入依赖(Gradle)

1
2
implementation 'com.auth0:java-jwt:3.4.0'
implementation 'org.springframework.boot:spring-boot-starter-security'

1

2

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();

// 允许cookies跨域
config.setAllowCredentials(true);
// 需要跨域的地址 * 表示对所有的地址都可以访问
config.setAllowedOriginPatterns(Collections.singletonList("*"));
// 跨域的请求头, *表示全部
config.setAllowedHeaders(Collections.singletonList("*"));
// 跨域的请求方法, *表示全部允许,也可以单独设置GET、PUT等
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); //认证提供者,它通过 UserDetailsService 加载用户信息并验证用户密码
daoAuthenticationProvider.setPasswordEncoder(new BCryptPasswordEncoder());
return new ProviderManager(daoAuthenticationProvider);
}


//过滤器链
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// http.formLogin(Customizer.withDefaults());
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");

// Enumeration<String> headerNames = request.getHeaderNames();
// System.out.println(headerNames.toString());
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、写死用户密码

  • 写死userDetail

  • 授权

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();
// return new BCryptPasswordEncoder();
}

//配置安全拦截机制
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/r/**").authenticated()//访问/r开始的请求需要认证通过
.anyRequest().permitAll()//其它请求全部放行
.and()
.formLogin().successForwardUrl("/login-success");//登录成功跳转到/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')")//拥有p1权限方可访问
public String r1(){
return "访问r1资源";
}

@RequestMapping("/r/r2")
@PreAuthorize("hasAuthority('p2')")//拥有p2权限方可访问
public String r2(){
return "访问r2资源";
}
...

访问不属于授权的资源会报错403

2

  1. 用户提交用户名、密码被SecurityFilterChain中的UsernamePasswordAuthenticationFilter过滤器获取到,封装为请求Authentication,通常情况下是UsernamePasswordAuthenticationToken这个实现类。

  2. 然后过滤器将Authentication提交至认证管理器(AuthenticationManager)进行认证

  3. 认证成功后,AuthenticationManager身份管理器返回一个被填充满了信息的(包括上面提到的权限信息,身份信息,细节信息,但密码通常会被移除)Authentication实例。

  4. SecurityContextHolder安全上下文容器将第3步填充了信息的Authentication,通过SecurityContextHolder.getContext().setAuthentication(…)方法,设置到其中。

  5. 可以看出AuthenticationManager接口(认证管理器)是认证相关的核心接口,也是发起认证的出发点,它的实现类为ProviderManager。而Spring Security支持多种认证方式,因此ProviderManager维护着一个List<AuthenticationProvider>列表,存放多种认证方式,最终实际的认证工作是由AuthenticationProvider完成的。咱们知道web表单的对应的AuthenticationProvider实现类为DaoAuthenticationProvider,它的内部又维护着一个UserDetailsService负责UserDetails的获取。最终AuthenticationProvider将UserDetails填充至Authentication。

三、OAuth2

比如微信扫码认证,是一种第三方认证方式,基于OAuth2协议实现。

基本流程:

3

4

  1. 客户端:本身不存储资源,需要通过资源拥有者的授权去请求资源服务器的资源,比如:手机客户端、浏览器等。上边示例中黑马网站即为客户端,它需要通过浏览器打开。

  2. 资源拥有者:通常为用户,也可以是应用程序,即该资源的拥有者。A表示客户端请求资源拥有者授权。B表示资源拥有者授权客户端即黑马网站访问自己的用户信息。

  3. 授权服务器(也称认证服务器)认证服务器对资源拥有者进行认证,还会对客户端进行认证并颁发令牌。C 客户端即黑马网站携带授权码请求认证。D认证通过颁发令牌。

  4. 资源服务器: 存储资源的服务器。E表示客户端即黑马网站携带令牌请求资源服务器获取资源。F表示资源服务器校验令牌通过后提供受保护资源。

1、授权码模式测试

5

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); // 令牌默认有效期2小时
service.setRefreshTokenValiditySeconds(259200); // 刷新令牌默认有效期3天
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()// 使用in-memory存储
.withClient("XcWebApp")// client_id
.secret("XcWebApp")//客户端密钥
// .secret(new BCryptPasswordEncoder().encode("XcWebApp"))//客户端密钥
.resourceIds("xuecheng-plus")//资源列表
.authorizedGrantTypes("authorization_code", "password","client_credentials","implicit","refresh_token")// 该client允许的授权类型authorization_code,password,refresh_token,implicit,client_credentials
.scopes("all")// 允许的授权范围
.autoApprove(false)//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()") //oauth/token_key是公开
.checkTokenAccess("permitAll()") //oauth/check_token公开
.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
  • client_id:客户端准入标识。

  • response_type:授权码模式固定为code。

  • scope:客户端权限。

  • redirect_uri:跳转uri,当授权码申请成功后会跳转到此地址,并在后边带上code参数(授权码)。

1、在访问页面选择同意

6

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一致。

得到结果

7

  • 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

也可以得到授权码

  • client_id:客户端准入标识。

  • client_secret:客户端秘钥。

  • grant_type:授权类型,填写password表示密码模式

  • username:资源拥有者用户名。

  • password:资源拥有者密码。

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;

//实例用于将 OAuth 2.0 访问令牌(JWT)进行编码和解码。JWT 转换器
@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); // 令牌默认有效期2小时
service.setRefreshTokenValiditySeconds(259200); // 刷新令牌默认有效期3天
return service;
}

}

再次访问

1
POST http://localhost:8160/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=zhangsan&password=123

得到JWT格式令牌

8

  • 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

9

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)//资源 id
.tokenStore(tokenStore)
.stateless(true);
}

@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
// .antMatchers("/r/**","/course/**").authenticated()//所有/r/**的请求必须认证通过
.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;
}

2

根据图片可知用户提交的账号密码通过

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;

// 根据账号查询用户信息 @param s 账号
@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();
//用户权限,如果不加报Cannot pass a null GrantedAuthority collection
String[] authorities= {"test"};
//创建UserDetails对象,权限信息待实现授权功能时再向UserDetail中加入
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 NoOpPasswordEncoder.getInstance();
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()// 使用in-memory存储
.withClient("XcWebApp")// client_id
// .secret("XcWebApp")//客户端密钥
.secret(new BCryptPasswordEncoder().encode("XcWebApp"))//客户端密钥
.resourceIds("xuecheng-plus")//资源列表
.authorizedGrantTypes("authorization_code", "password","client_credentials","implicit","refresh_token")// 该client允许的授权类型authorization_code,password,refresh_token,implicit,client_credentials
.scopes("all")// 允许的授权范围
.autoApprove(false)//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;

// 根据账号查询用户信息 @param s 账号
@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();
//用户权限,如果不加报Cannot pass a null GrantedAuthority collection
String[] authorities = {"p1"};
//为了安全在令牌中不放密码
user.setPassword(null);
//将user对象转json
String userString = JSON.toJSONString(user);
//创建UserDetails对象
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();
//将json转成对象
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;
}
}

10

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;//验证码key
private String authType; // 认证的类型 password:用户名密码模式类型 sms:短信模式类型 wx:微信
private Map<String, Object> payload = new HashMap<>();//附加数据,作为扩展,不同认证类型可拥有不同的附加数据。如认证类型为短信时包含smsKey : sms:3d21042d054548b08477142bbca95cfa; 所有情况下都包含clientId
}

修改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;

/**
* @description 查询用户信息组成用户身份信息
* @param s AuthParamsDto类型的json数据
* @return org.springframework.security.core.userdetails.UserDetails
*/
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {

AuthParamsDto authParamsDto = null;
try {
//将认证参数转为AuthParamsDto类型
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();
//用户权限,如果不加报Cannot pass a null GrantedAuthority collection
String[] authorities = {"p1"};
//将user对象转json
String userString = JSON.toJSONString(user);
//创建UserDetails对象
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
/**
* @description 自定义DaoAuthenticationProvider
*/
@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;

/**
* @description 认证service
*/
public interface AuthService {

/**
* @description 认证方法
* @param authParamsDto 认证参数
* @return com.xuecheng.ucenter.model.po.XcUser 用户信息
*/
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;


/**
* @description 查询用户信息组成用户身份信息
* @param s AuthParamsDto类型的json数据
* @return org.springframework.security.core.userdetails.UserDetails
* @author Mr.M
* @date 2022/9/28 18:30
*/
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {

AuthParamsDto authParamsDto = null;
try {
//将认证参数转为AuthParamsDto类型
authParamsDto = JSON.parseObject(s, AuthParamsDto.class);
} catch (Exception e) {
log.info("认证请求不符合项目要求:{}",s);
throw new RuntimeException("认证请求数据格式不对");
}
//开始认证
authService.execute(authParamsDto);

...

11

到目前为止,登录流程修改如上

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
/**
* @description 账号密码认证
*/
@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
//@description 自定义UserDetailsService用来对接Spring Security
@Slf4j
@Service
public class UserServiceImpl implements UserDetailsService {

@Autowired
XcUserMapper xcUserMapper;

@Autowired
ApplicationContext applicationContext;

// @Autowired
// AuthService authService;

//查询用户信息组成用户身份信息 @param s AuthParamsDto类型的json数据
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {

AuthParamsDto authParamsDto = null;
try {
//将认证参数转为AuthParamsDto类型
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);
}

// @description 查询用户信息。@param user 用户id,主键
public UserDetails getUserPrincipal(XcUserExt user){
//用户权限,如果不加报Cannot pass a null GrantedAuthority collection
String[] authorities = {"p1"};
String password = user.getPassword();
//为了安全在令牌中不放密码
user.setPassword(null);
//将user对象转json
String userString = JSON.toJSONString(user);
//创建UserDetails对象
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从缓存取出正确的验证码和用户输入的验证码进行比对,如果相同则校验通过,否则不通过。

12

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

13

同时开发远程调用验证码服务的接口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协议的授权码模式

14

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

15

用户允许授权后,将会重定向到redirect_uri的网址上,并且带上 code 和state参数

1
redirect_uri?code=CODE&state=STATE

还有一种方式就是内嵌二维码

  1. 引入js

1
http://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js
  1. 定义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){

//收到code调用微信接口申请access_token
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");
//拿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();
//根据unionid查询数据库
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){

//收到code调用微信接口申请access_token
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");
//拿access_token查询用户信息
Map<String, String> userinfo = getUserinfo(access_token, openid);
if(userinfo==null){
return null;
}
//将用户信息保存到数据库
XcUser xcUser = currentProxy.addWxUser(userinfo);
return xcUser;
}

五、RABC

RBAC分为两种方式:

  1. 基于角色的访问控制(Role-Based Access Control)

    按照角色进行授权,例如班主任可以查询全部学生的成绩,信息等。

    1
    2
    3
    if(主体.hasRole("总经理角色id") ||  主体.hasRole("部门经理角色id")){
    查询工资
    }

    缺点很明显,如果某个方法多种角色都可以操作,那么就需要判断很多角色,系统扩展性差

  2. 基于资源的访问控制(Resource-Based Access Control)

    判断主体有无此资源的访问权限

    1
    2
    3
    if(主体.hasPermission("查询工资权限标识")){
    查询工资
    }

​ 优点:系统设计时定义好查询工资的权限标识,即使查询工资所需要的角色变化为总经理和部门经理也不需要修改授权代码,系统可扩展性强。

1、资源服务集成Spring Security

在需要授权的接口处使用@PreAuthorize(“hasAuthority(‘权限标识符’)”)进行控制

下边代码指定/course/list接口需要拥有xc_teachmanager_course_list 权限。

16

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、网页访问调用对应接口提示没有权限

17

学习一下权限控制数据模型

18

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){
//用户权限,如果不加则报Cannot pass a null GrantedAuthority collection
permissions.add("p1");
}else{
xcMenus.forEach(menu->{
permissions.add(menu.getCode());
});
}
//将用户权限放在XcUserExt中
user.setPermissions(permissions);

//为了安全在令牌中不放密码
user.setPassword(null);
//将user对象转json
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();
//机构id
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<>();
//机构id
queryWrapper.eq(CourseBase::getCompanyId,companyId);
....

六、支付接入

我们这里采用支付宝沙箱模拟接入支付

1、前置操作

最好有一个通用订单服务,能够处理订单生成、支付、退款、接收支付通知等操作。

19

比如学成在线选课时就需要支付课程费用,我们这里就用到了订单表,定义了一些相关字段。

2、配置沙箱环境

1、进入支付宝开放平台沙箱小程序

沙箱应用 - 开放平台 (alipay.com)

20

申请沙箱应用,使用公钥模式,看到APPID等信息

21

点击查看公钥,可以看到应用公钥应用私钥支付宝公钥等信息

22

点击沙箱账号,可以进行虚拟充值

23

2、手机或模拟器安装支付宝沙箱版

沙箱工具 - 开放平台 (alipay.com)

3、接口介绍与使用

25

1)用户在商户的H5网站下单支付后,商户系统按照手机网站支付接口alipay.trade.wap.payAPI的参数规范生成订单数据

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
<!-- 支付宝SDK -->
<dependency>
<groupId>com.alipay.sdk</groupId>
<artifactId>alipay-sdk-java</artifactId>
<version>3.7.73.ALL</version>
</dependency>

<!-- 支付宝SDK依赖的日志 -->
<dependency>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
<version>1.2</version>
</dependency>

创建订单接口,当然需要在配置文件中配置好APP_IDAPP_PRIVATE_KEYALIPAY_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);
//获得初始化的AlipayClient
AlipayTradeWapPayRequest alipayRequest = new AlipayTradeWapPayRequest();//创建API对应的request
//alipayRequest.setReturnUrl("http://domain.com/CallBack/return_url.jsp");
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(); //调用SDK生成表单
httpResponse.setContentType("text/html;charset=" + AlipayConfig.CHARSET);
httpResponse.getWriter().write(form);//直接将完整的表单html输出到页面
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); //获得初始化的AlipayClient
AlipayTradeQueryRequest request = new AlipayTradeQueryRequest();
JSONObject bizContent = new JSONObject();
bizContent.put("out_trade_no", "1846935012016668672");
//bizContent.put("trade_no", "2014112611001004680073956707");
request.setBizContent(bizContent.toString());
AlipayTradeQueryResponse response = alipayClient.execute(request);
if (response.isSuccess()) {
System.out.println("调用成功");
String resultJson = response.getBody();
//转map
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、支付结果通知接口

28

根据下单执行流程,订单服务收到支付结果需要对内容进行验签,验签过程如下:

  1. 在通知返回参数列表中,除去sign、sign_type两个参数外,凡是通知返回回来的参数皆是待验签的参数。将剩下参数进行 url_decode,然后进行字典排序,组成字符串,得到待签名字符串; 生活号异步通知组成的待验签串里需要保留 sign_type 参数。

  2. 将签名参数(sign)使用 base64 解码为字节码串;

  3. 使用 RSA 的验签方法,通过签名字符串、签名参数(经过 base64 解码)及支付宝公钥验证签名。

  4. 验证签名正确后,必须再严格按照如下描述校验通知数据的正确性。

在上述验证通过后,商户必须根据支付宝不同类型的业务通知,正确的进行不同的业务处理,并且过滤重复的通知结果数据。

通过验证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 {
//构造sdk的客户端对象
AlipayClient alipayClient = new DefaultAlipayClient(serverUrl, APP_ID, APP_PRIVATE_KEY, "json", CHARSET, ALIPAY_PUBLIC_KEY, sign_type); //获得初始化的AlipayClient
AlipayTradeWapPayRequest alipayRequest = new AlipayTradeWapPayRequest();//创建API对应的request
// alipayRequest.setReturnUrl("http://domain.com/CallBack/return_url.jsp");
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] + ",";
}
//乱码解决,这段代码在出现乱码时使用。如果mysign和sign不相等也可以使用这段代码转化
//valueStr = new String(valueStr.getBytes("ISO-8859-1"), "gbk");
params.put(name, valueStr);
}

//获取支付宝的通知返回参数,可参考技术文档中页面跳转同步通知参数列表(以上仅供参考)//
//计算得出通知验证结果
//boolean AlipaySignature.rsaCheckV1(Map<String, String> params, String publicKey, String charset, String sign_type)
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")) {//交易结束
//判断该笔订单是否在商户网站中已经做过处理
//如果没有做过处理,根据订单号(out_trade_no)在商户网站的订单系统中查到该笔订单的详细,并执行商户的业务程序
//请务必判断请求时的total_fee、seller_id与通知时获取的total_fee、seller_id为一致的
//如果有做过处理,不执行商户的业务程序

//注意:
//如果签约的是可退款协议,退款日期超过可退款期限后(如三个月可退款),支付宝系统发送该交易状态通知
//如果没有签约可退款协议,那么付款完成后,支付宝系统发送该交易状态通知。
} else if (trade_status.equals("TRADE_SUCCESS")) {//交易成功
System.out.println(trade_status);
//判断该笔订单是否在商户网站中已经做过处理
//如果没有做过处理,根据订单号(out_trade_no)在商户网站的订单系统中查到该笔订单的详细,并执行商户的业务程序
//请务必判断请求时的total_fee、seller_id与通知时获取的total_fee、seller_id为一致的
//如果有做过处理,不执行商户的业务程序

//注意:
//如果签约的是可退款协议,那么付款完成后,支付宝系统发送该交易状态通知。
}
response.getWriter().write("success");
}else{
response.getWriter().write("fail");
}
}

4、端口映射

这里采用NATAPP-内网穿透 基于ngrok的国内高速内网映射工具

27

注册登录后,添加免费WEB隧道,下载客户端,配置config.ini中的authtoken

26

双击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、业务代码简略

核心代码再接口介绍与使用,这里不介绍具体的业务代码,没必要,根据自身业务逻辑再去编写。

订单支付模式的核心由三张表组成:订单表、订单明细表、支付记录表。

29

支付记录表存在的意义:

如果我们直接将订单表订单号传入第三方支付平台,当第三方支付平台此订单支付交易关闭的时候,再想支付相同订单就很难了。所以我们引入支付记录表,记录订单id,并且生成唯一的out_pay_no。可以解决问题。

解决以上问题的方案是:

1、用户每次发起支付都创建一个新的支付记录 ,此支付记录与商品订单关联。

2、将支付记录的流水号传给第三方支付系统的下单接口,这样即可解决上边的问题。

3、不过在程序中要考虑重复支付的问题。

订单号注意唯一性、安全性、尽量短等特点,生成方案常用的如下:

方案 描述
时间戳+随机数 年月日时分秒毫秒 + 随机数
高并发场景 年月日时分秒毫秒 + 随机数 + Redis 自增序列
订单号中加上业务标识 订单号加上业务标识方便客服,比如:第 10 位是业务类型,第 11 位是用户类型等。
雪花算法 雪花算法是推特内部使用的分布式环境下的唯一 ID 生成算法,基于时间戳生成,保证有序递增,加上计算机硬件等元素,满足高并发环境下 ID 不重复。

雪花算法:

1
2
//生成支付交易流水号
long payNo = IdWorkerUtils.getInstance().nextId();

重复支付问题:

上边实现中,扫码下单时会根据支付记录号判断是否支付完成,生成二维码时也会判断订单的状态是否支付完成,如果支付完成将不再重新支付,即使这样做也无法绝对避免重复支付。

正常情况下同一个用户的同一个订单不会存在并发扫码支付的问题,但系统是可能存在重复支付的问题的,解决该问题需要单独定义任务,每隔24小时查询前一天的订单是否存在重复支付的问题,如果存在则调用第三方支付平台的退款接口进行退款。