废话
目前流行的前后端分离让Java程序员可以更加专注的做好后台业务逻辑的功能实现,提供如返回Json格式的数据接口就可以。SpringBoot的易用性和对其他框架的高度集成,用来快速开发一个小型应用是最佳的选择。
一套前后端分离的后台项目,刚开始就要面对的就是登陆和授权的问题。这里提供一套方案供大家参考。
主要看点:
- 登陆后获取token,根据token来请求资源
- 根据用户角色来确定对资源的访问权限
- 统一异常处理
- 返回标准的Json格式数据
正文
首先是pom文件:
org.springframework.boot
spring-boot-starter
org.projectlombok
lombok
true
org.springframework.boot
spring-boot-starter-data-solr
org.springframework.boot
spring-boot-starter-web
org.mybatis.spring.boot
mybatis-spring-boot-starter
1.3.2
mysql
mysql-connector-java
runtime
org.springframework.boot
spring-boot-configuration-processor
true
io.springfox
springfox-swagger2
2.6.1
io.springfox
springfox-swagger-ui
2.6.1
org.springframework.boot
spring-boot-starter-data-rest
org.springframework.boot
spring-boot-starter-security
org.springframework.security
spring-security-jwt
1.0.9.RELEASE
io.jsonwebtoken
jjwt
0.9.0
org.springframework.boot
spring-boot-starter-test
test
application.yml:
spring :
datasource :
url : jdbc:mysql://127.0.0.1:3306/les_data_center?useUnicode=true&characterEncoding=UTF-8&allowMultiQueries=true&useAffectedRows=true&useSSL=false
username : root
password : 123456
driverClassName : com.mysql.jdbc.Driver
jackson:
data-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
mybatis :
config-location : classpath:/mybatis-config.xml
# JWT
jwt:
header: Authorization
secret: mySecret
#token有效期一天
expiration: 86400
tokenHead: "Bearer "
接着是对security的配置,让security来保护我们的API
SpringBoot推荐使用配置类来代替xml配置。那这里,我也使用配置类的方式。
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtAuthenticationEntryPoint unauthorizedHandler;
private final AccessDeniedHandler accessDeniedHandler;
private final UserDetailsService CustomUserDetailsService;
private final JwtAuthenticationTokenFilter authenticationTokenFilter;
@Autowired
public WebSecurityConfig(JwtAuthenticationEntryPoint unauthorizedHandler,
@Qualifier("RestAuthenticationAccessDeniedHandler") AccessDeniedHandler accessDeniedHandler,
@Qualifier("CustomUserDetailsService") UserDetailsService CustomUserDetailsService,
JwtAuthenticationTokenFilter authenticationTokenFilter) {
this.unauthorizedHandler = unauthorizedHandler;
this.accessDeniedHandler = accessDeniedHandler;
this.CustomUserDetailsService = CustomUserDetailsService;
this.authenticationTokenFilter = authenticationTokenFilter;
}
@Autowired
public void configureAuthentication(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
authenticationManagerBuilder
// 设置UserDetailsService
.userDetailsService(this.CustomUserDetailsService)
// 使用BCrypt进行密码的hash
.passwordEncoder(passwordEncoder());
}
// 装载BCrypt密码编码器
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.exceptionHandling().accessDeniedHandler(accessDeniedHandler).and()
// 由于使用的是JWT,我们这里不需要csrf
.csrf().disable()
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests()
// 对于获取token的rest api要允许匿名访问
.antMatchers("/api/v1/auth", "/api/v1/signout", "/error/**", "/api/**").permitAll()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
// 禁用缓存
httpSecurity.headers().cacheControl();
// 添加JWT filter
httpSecurity
.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/v2/api-docs",
"/swagger-resources/configuration/ui",
"/swagger-resources",
"/swagger-resources/configuration/security",
"/swagger-ui.html"
);
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
该类中配置了几个bean来供security使用。
- JwtAuthenticationTokenFilter:token过滤器来验证token有效性
- UserDetailsService:实现了DetailsService接口,用来做登陆验证
- JwtAuthenticationEntryPoint :认证失败处理类
- RestAuthenticationAccessDeniedHandler: 权限不足处理类
那么,接下来一个一个实现这些类:
/**
* token校验,引用的stackoverflow一个答案里的处理方式
* Author: JoeTao
* createAt: 2018/9/14
*/
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Value("${jwt.header}")
private String token_header;
@Resource
private JWTUtils jwtUtils;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
String auth_token = request.getHeader(this.token_header);
final String auth_token_start = "Bearer ";
if (StringUtils.isNotEmpty(auth_token) && auth_token.startsWith(auth_token_start)) {
auth_token = auth_token.substring(auth_token_start.length());
} else {
// 不按规范,不允许通过验证
auth_token = null;
}
String username = jwtUtils.getUsernameFromToken(auth_token);
logger.info(String.format("Checking authentication for user %s.", username));
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
User user = jwtUtils.getUserFromToken(auth_token);
if (jwtUtils.validateToken(auth_token, user)) {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
logger.info(String.format("Authenticated user %s, setting security context", username));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
chain.doFilter(request, response);
}
}
/**
* 认证失败处理类,返回401
* Author: JoeTao
* createAt: 2018/9/20
*/
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable {
private static final long serialVersionUID = -8970718410437077606L;
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
//验证为未登陆状态会进入此方法,认证错误
System.out.println("认证失败:" + authException.getMessage());
response.setStatus(200);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
PrintWriter printWriter = response.getWriter();
String body = ResultJson.failure(ResultCode.UNAUTHORIZED, authException.getMessage()).toString();
printWriter.write(body);
printWriter.flush();
}
}
因为我们使用的REST API,所以我们认为到达后台的请求都是正常的,所以返回的HTTP状态码都是200,用接口返回的code来确定请求是否正常。
/**
* 权限不足处理类,返回403
* Author: JoeTao
* createAt: 2018/9/21
*/
@Component("RestAuthenticationAccessDeniedHandler")
public class RestAuthenticationAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest httpServletRequest, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
//登陆状态下,权限不足执行该方法
System.out.println("权限不足:" + e.getMessage());
response.setStatus(200);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
PrintWriter printWriter = response.getWriter();
String body = ResultJson.failure(ResultCode.FORBIDDEN, e.getMessage()).toString();
printWriter.write(body);
printWriter.flush();
}
}
/**
* 登陆身份认证
* Author: JoeTao
* createAt: 2018/9/14
*/
@Component(value="CustomUserDetailsService")
public class CustomUserDetailsService implements UserDetailsService {
private final AuthMapper authMapper;
public CustomUserDetailsService(AuthMapper authMapper) {
this.authMapper = authMapper;
}
@Override
public User loadUserByUsername(String name) throws UsernameNotFoundException {
User user = authMapper.findByUsername(name);
if (user == null) {
throw new UsernameNotFoundException(String.format("No user found with username '%s'.", name));
}
Role role = authMapper.findRoleByUserId(user.getId());
user.setRole(role);
return user;
}
}
登陆逻辑:
public ResponseUserToken login(String username, String password) {
//用户验证
final Authentication authentication = authenticate(username, password);
//存储认证信息
SecurityContextHolder.getContext().setAuthentication(authentication);
//生成token
final User user = (User) authentication.getPrincipal();
// User user = (User) userDetailsService.loadUserByUsername(username);
final String token = jwtTokenUtil.generateAccessToken(user);
//存储token
jwtTokenUtil.putToken(username, token);
return new ResponseUserToken(token, user);
}
private Authentication authenticate(String username, String password) {
try {
//该方法会去调用userDetailsService.loadUserByUsername()去验证用户名和密码,如果正确,则存储该用户名密码到“security 的 context中”
return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
} catch (DisabledException | BadCredentialsException e) {
throw new CustomException(ResultJson.failure(ResultCode.LOGIN_ERROR, e.getMessage()));
}
}
自定义异常:
@Getter
public class CustomException extends RuntimeException{
private ResultJson resultJson;
public CustomException(ResultJson resultJson) {
this.resultJson = resultJson;
}
}
统一异常处理:
/**
* 异常处理类
* controller层异常无法捕获处理,需要自己处理
* Created by jt on 2018/8/27.
*/
@RestControllerAdvice
@Slf4j
public class DefaultExceptionHandler {
/**
* 处理所有自定义异常
* @param e
* @return
*/
@ExceptionHandler(CustomException.class)
public ResultJson handleCustomException(CustomException e){
log.error(e.getResultJson().getMsg().toString());
return e.getResultJson();
}
}
所有经controller转发的请求抛出的自定义异常都会被捕获处理,一般情况下就是返回给调用方一个json的报错信息,包含自定义状态码、错误信息及补充描述信息。
值得注意的是,在请求到达controller之前,会被Filter拦截,如果在controller或者之前抛出的异常,自定义的异常处理器是无法处理的,需要自己重新定义一个全局异常处理器或者直接处理。
Filter拦截请求两次的问题
跨域的post的请求会验证两次,get不会。网上的解释是,post请求第一次是预检请求,Request Method: OPTIONS。
解决方法:
在webSecurityConfig里添加
.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
就可以不拦截options请求了。
这里只给出了最主要的代码,还有controller层的访问权限设置,返回状态码,返回类定义等等。
所有代码已上传GitHub,项目地址
2021-1-28日更新:
新增基于spring security,jwt,spring boot的动态菜单和权限管理demo,新项目地址
2023-03 更新:
目前博主已上线个人博客 皮皮&夏天。