依赖
dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-consul-discovery'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
testImplementation 'org.springframework.security:spring-security-test'
implementation 'com.github.ladyishenlong:response-utils:1.0'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
}
- 使用gradle构建的项目
- 主要是spring security的依赖以及jjwt作为token生成和解析的工具
- 关于jwt推荐一个网站:https://jwt.io/
- consul是注册中心,与本文无关可以去除
用户信息
@Data
@Component
public class Student {
public String username = "123";
public String password = "456";
public String verificationcode = "789"; //验证码
public String secret = "secretKey"; // token的密钥
public Set<GrantedAuthority> authorities; //用户权限
public Set<GrantedAuthority> getAuthorities() {
Set<GrantedAuthority> authorities = new HashSet<>();
authorities.add(new SimpleGrantedAuthority("root"));
authorities.add(new SimpleGrantedAuthority("admin"));
return authorities;
}
}
- 随意编写了模拟数据库内用户信息的类,使用@Autowired注入
spring security配置
这里先说几句,用jwt进行用户验证其实并不难,自己写个过滤器或者拦截器总能搞定,但麻烦的是如何与spring security框架进行集成,原因自然是因为spring security框架原本使用的是用户名,密码以及session进行登录验证的,于是需要对这些验证的地方进行改造才行
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private LoginUserDetailsService loginUserDetailsService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.headers().cacheControl();//禁用缓存
http
.cors()
.and()
.formLogin().disable()
.csrf().disable()//禁用csrf防护
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)//关闭session
.and()
//配置请求的权限
.authorizeRequests()
.antMatchers("/login").permitAll()
.antMatchers("/test").permitAll()
//需要特定用户的权限
.antMatchers("/test3").access(
"@AuthService.role('bigboss',request)")
//普通的请求
.anyRequest().access("@AuthService.auth(request)")
.and()
.authenticationProvider(getLoginAuthProvider())
.httpBasic()
.and()
.exceptionHandling()
//未授权处理
.authenticationEntryPoint(new UnAuthorizedEntryPoint())
.and()
.addFilterBefore(new UserAuthFilter("/login", authenticationManager()),
UsernamePasswordAuthenticationFilter.class)
;
}
@Bean
public LoginAuthProvider getLoginAuthProvider() {
//采用该方式初始化,在LoginAuthProvider中除了构造函数之外可以依赖注入
return new LoginAuthProvider(loginUserDetailsService);
}
}
- 这是security的配置,用@EnableWebSecurity开启security的防护,@EnableWebSecurity包含了@Configuration,而@Configuration包含了@Component注解,自然无需再加上这些注解了
- 这个类继承自WebSecurityConfigurerAdapter类重写了configure方法
- 因为要使用jwt做验证,所以讲session先关闭掉,至于configure中对security的改动接下来就截取出来一点一点说
登录请求
- 首先说的是登录请求,在configure方法中的代码是:
.and()
.addFilterBefore(new UserAuthFilter("/login", authenticationManager()),
UsernamePasswordAuthenticationFilter.class)
- addFilterBefore方法的作用是在指定的过滤器的顺序之前再添加一个过滤器,这里我是在UsernamePasswordAuthenticationFilter.class过滤器前添加了个自定义的过滤器
@Slf4j
public class UserAuthFilter extends UsernamePasswordAuthenticationFilter {
private static final String VERIFICATION_CODE = "verificationcode";//验证码
public UserAuthFilter(String defaultFilterProcessesUrl, AuthenticationManager authenticationManager) {
setFilterProcessesUrl(defaultFilterProcessesUrl);
setAuthenticationManager(authenticationManager);
}
/**
* 登录接口,用户粗
*
* @param request
* @param response
* @return
* @throws AuthenticationException
*/
@Override
public Authentication attemptAuthentication(
HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
String username = obtainUsername(request);//用户名
String password = obtainPassword(request);//密码
String verificationcode = obtainVerificationCode(request);//验证码
if (StringUtils.isEmpty(username)) username = "";
if (StringUtils.isEmpty(password)) password = "";
if (StringUtils.isEmpty(verificationcode)) verificationcode = "";
UserAuthToken userAuthToken = new UserAuthToken(username, password, verificationcode);
//将post请求传入的用户信息放入
return getAuthenticationManager().authenticate(userAuthToken);
}
/**
* 登录请求成功返回的的信息
* 在这里返回的是token
*
* @param request
* @param response
* @param chain
* @param authResult
* @throws IOException
* @throws ServletException
*/
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, Authentication authResult) throws IOException, ServletException {
UserModel userModel = (UserModel) (authResult.getPrincipal());
//生成token
String token = TokenUtils.createToken(userModel.getUsername(),
userModel.getSecret(),
userModel.getAuthorities());
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
out.write(new ObjectMapper().writeValueAsString(
ResponseUtils.success("登录成功", token)));
out.flush();
out.close();
}
/**
* 登录请求失败返回的信息
*
* @param request
* @param response
* @param failed
* @throws IOException
* @throws ServletException
*/
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) throws IOException, ServletException {
//也可以设置401状态码
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
out.write(new ObjectMapper().writeValueAsString(
ResponseUtils.failure(failed.getMessage())));
out.flush();
out.close();
}
@Nullable
protected String obtainVerificationCode(HttpServletRequest request) {
return request.getParameter(VERIFICATION_CODE);
}
- 可以看到这个过滤器传入的defaultFilterProcessesUrl是过滤的url,这里我传入的是/login,作为登陆的接口
- 这个类继承自UsernamePasswordAuthenticationFilter,UsernamePasswordAuthenticationFilter的作用就是从request中拿到用户名密码,传到security框架之中,我这里就只是多加了个验证码,这步操作是在attemptAuthentication方法之中进行的
- 其中security原本使用的UsernamePasswordAuthenticationToken类只有用户名密码,所以只能编写个UserAuthToken类继承其之后再加个验证码:
@Data
public class UserAuthToken extends UsernamePasswordAuthenticationToken {
private Object verificationcode;
public UserAuthToken(Object principal, Object credentials, Object verificationcode) {
super(principal, credentials);
this.verificationcode = verificationcode;
}
public UserAuthToken(Object principal) {
super(principal, "");
}
}
- 注意这里的attemptAuthentication方法中可以抛出AuthenticationException异常,这会导致进入用户登录验证失败的方法之中(也就是unsuccessfulAuthentication方法 ),但是由于UserAuthFilter是继承自一个Filter类,此时还没进入spring容器之中,无法使用依赖注入,无法使用speingboot查询数据库的框架,因此这里只需要做的就是把需要用来验证的数据传入security框架之中即可
- 在UserAuthFilter中 剩余的的两个方法则是登录请求成功或者失败的回调,失败了很简单,只要返回401状态码或者返回一个登录失败的信息即可,而登录成功则需要返回生成的token,其中需要携带用户名和用户的权限,这个是jjwt包里面的东西,之后会再说
- 简单来说登录失败进入unsuccessfulAuthentication方法,登录成功进入successfulAuthentication方法,而登录失败的原因一般就是AuthenticationException异常
查询数据库中的用户信息
- 承接上一步,已经通过登录接口/login将用户名密码以及验证码传到了框架之中,现在需要的就是根据用户名来查询数据库里面的信息了:
@Slf4j
@Component
public class LoginUserDetailsService implements UserDetailsService {
@Autowired
private Student student;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//密码需要加密
PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
//TODO 如果用户没有被查询到,可以直接抛出 UsernameNotFoundException
//TODO 数据库查询 获取 用户名 密码 secret 权限 验证码 等信息
//TODO spring security 框架默认用户名密码,如果使用验证码方式,直接在后台写死一个固定密码,否则会有问题
//测试环境 写死一个用户信息
return new UserModel(student.getUsername(), encoder.encode(student.getPassword()),
student.getSecret(), student.getVerificationcode(), student.getAuthorities());
}
}
- 这一步就是从数据库查询用户信息的地方,然后将查询到的信息也传入 springsecurity框架之中
- 另外验证码一般是存在redis之中,过期了查询不到的话也可以抛出UsernameNotFoundException 异常,它继承于AuthenticationException,抛出的话会进入到 UserAuthFilter中的unsuccessfulAuthentication方法
@Data
public class UserModel extends User {
private String secret;
private String verificationcode;
public UserModel(String username, String password, String secret, String verificationcode,
Collection<? extends GrantedAuthority> authorities) {
super(username, password, authorities);
this.secret = secret;
this.verificationcode = verificationcode;
}
}
- 原本的user没有验证码和用户生成token的secret,在这里就继承security框架中的user类自己写一个
验证登录
- 用户请求上传的用户信息和从数据库中查询到的用户信息都已经得到了,接下来只要进行比对即可
- spring security框架中是在DaoAuthenticationProvider类中进行校验,自然这里只能校验密码,所以这里又需要继承后重新写个:
@Slf4j
public class LoginAuthProvider extends DaoAuthenticationProvider {
public LoginAuthProvider(LoginUserDetailsService loginUserDetailsService) {
super();
setUserDetailsService(loginUserDetailsService);//必须设置
}
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
//todo 还能做一些非空的判断
UserModel userModel = (UserModel) userDetails;//从数据库查出来的参数
UserAuthToken userAuthToken = (UserAuthToken) authentication;//登录请求携带的参数
if (!getPasswordEncoder().matches(userAuthToken.getCredentials().toString(),
userModel.getPassword()))
throw new BadCredentialsException("密码错误");
if (!userAuthToken.getVerificationcode()
.equals(userModel.getVerificationcode()))
throw new BadCredentialsException("验证码错误");
log.info("验证用户:{},{}", userModel, userAuthToken);
}
}
- 除了密码的比对,这里也加上了验证码的比对,其中的BadCredentialsException也是继承于AuthenticationExceptiony如果抛出也会进入unsuccessfulAuthentication方法
spring security配置说明
- 到这里登录请求的流程基本明确
UserAuthFilter //将/login中的用户名密码传入框架,同时定义了登录成功 和登录失败的方法
LoginUserDetailsService//通过用户名从数据库查询用户信息
LoginAuthProvider //比对上面两个类的信息,校验用户
- 当然这些类是需要在SecurityConfig类中进行配置的,完整的代码之前已经贴出,这里就贴一下配置这三个类的地方
- UserAuthFilter
.and()
.addFilterBefore(new UserAuthFilter("/login", authenticationManager()),
UsernamePasswordAuthenticationFilter.class)
- loginUserDetailsService 和 LoginAuthProvider 是一起的
@Autowired
private LoginUserDetailsService loginUserDetailsService;
@Bean
public LoginAuthProvider getLoginAuthProvider() {
//采用该方式初始化,在LoginAuthProvider中除了构造函数之外可以依赖注入
return new LoginAuthProvider(loginUserDetailsService);
}
.and()
.authenticationProvider(getLoginAuthProvider())
- 到这里,登录请求使用jwt的已经完成
普通请求
- 在进行完登录请求之后,获得了token,普通的请求需要带上token请求,因为原本的security框架使用的session,自然普通token请求的验证也需要自己来编写
- 首先编写的是授权未通过时候的操作
.and()
.exceptionHandling()
//未授权处理
.authenticationEntryPoint(new UnAuthorizedEntryPoint())
public class UnAuthorizedEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
int code = AuthExceptionCode.getCode(authException.getMessage());
String reason = AuthExceptionCode.getReason(authException.getMessage());
if (code == 0) reason = authException.getMessage();
WriteUtils.writeJson(response, ResponseUtils.failure(code, reason, null));
}
}
- 这里我自己定义了几个code根据不同情况返回,一般直接在这里返回401状态码就好了
- commence方法中有传入AuthenticationException参数,这是普通授权失败时候抛出的异常,可以从中获得自己传入信息
- 接下来这块就是普通请求的授权过程了
//配置请求的权限
.authorizeRequests()
.antMatchers("/login").permitAll()
.antMatchers("/test").permitAll()
//需要特定用户的权限
.antMatchers("/test3").access(
"@AuthService.role('bigboss',request)")
//普通的请求
.anyRequest().access("@AuthService.auth(request)")
- 这一部分使用的是权限表达式
- permitAll方法因为是无需授权的请求,所以用原来的.permitAll()也行,但是需要用户权限的不能使用原本的.hasRole()以及.authenticated()方法,因为这两个方法无法验证token的有效性,就会出现问题
- 首先需要写个类:
@Slf4j
@Component(value = "AuthService")
public class AuthService {
@Autowired
private Student student;
/**
* 普通请求认证
*
* @param request
* @return
* @throws AuthenticationException
*/
public boolean auth(HttpServletRequest request)
throws AuthenticationException {
String token = request.getHeader(TokenUtils.AUTHORIZATION);
//解析密钥是后台查询的
Claims claims = TokenUtils.parserToken(token, student.getSecret());
//用户名
String username = claims.getSubject();
UserAuthToken userAuthToken = new UserAuthToken(username);
//设置Context
SecurityContextHolder.getContext()
.setAuthentication(userAuthToken);
return true;
}
/**
* 单个 用户权限验证
*
* @param role
* @param request
* @return
*/
public boolean role(String role, HttpServletRequest request) {
String token = request.getHeader(TokenUtils.AUTHORIZATION);
//解析密钥是后台查询的
Claims claims = TokenUtils.parserToken(token, student.getSecret());
//用户名
String username = claims.getSubject();
//权限
List<LinkedHashMap<String, String>> authorities =
(List<LinkedHashMap<String, String>>) (claims.get("authorities"));
boolean hasRole = false;
for (LinkedHashMap<String, String> authority : authorities) {
if (authority.get("authority").equals(role)) {
hasRole = true;
break;
}
}
if (!hasRole) throw new BadCredentialsException("没有访问该接口的权限");
UserAuthToken userAuthToken = new UserAuthToken(username);
SecurityContextHolder.getContext()
.setAuthentication(userAuthToken);
return true;
}
}
- 其中@Component(value = "AuthService")很重要
- 两个方法简单来说就是获取request中的token进行解析,如果有问题或者失效了,就抛出AuthenticationException异常就会到UnAuthorizedEntryPoint中返回给前台,最后return true了就ton过了校验
- 注意需要使用 SecurityContextHolder.getContext().setAuthentication()设置用户信息,这样就可以在接口中获取到,一般来说只要放入用户名即可
- 另外生成token的secret是存在数据库中的,解析的时候需要进行查询,如果需要将尚未失效的token作废,可以设计一个接口废弃原本的secret然后生成新的secret也是一种思路
token生成与解析
public static String createToken(String username, String secret,
Collection<GrantedAuthority> authorities) {
return Jwts.builder()
.setSubject(username)
.claim(AUTHORITIES, authorities)//配置用户权限(角色)
.setIssuedAt(DateUtils.createDate()) //设置token发布时间
.setExpiration(DateUtils.expirationDate(TIME_OUT))//设置过期时间
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public static Claims parserToken(String token, String secret) throws AuthenticationException {
try {
return Jwts.parser().setSigningKey(secret)
.parseClaimsJws(token).getBody();
} catch (ExpiredJwtException e) {
throw new BadCredentialsException(AuthExceptionCode.EXPIRED.getCodeValue());
} catch (IllegalArgumentException e) {
throw new BadCredentialsException(AuthExceptionCode.EMPTY.getCodeValue());
} catch (SignatureException e) {
throw new BadCredentialsException(AuthExceptionCode.SIGN.getCodeValue());
} catch (MalformedJwtException e) {
throw new BadCredentialsException(AuthExceptionCode.MALFORMED.getCodeValue());
} catch (UnsupportedJwtException e) {
throw new BadCredentialsException(AuthExceptionCode.UNSUPPORTED.getCodeValue());
}catch (Exception e){
throw new BadCredentialsException(AuthExceptionCode.UNKNOW.getCodeValue());
}
}
- 最后分享余下生成与解析的token的两个方法,其中解析的各种异常我读分别捕获了出来,可以用户返回给前台异常信息
- 具体的可以参考头部的git中 security-token-service 下的代码