前端要登录,需要调用登录接口,因此后端必须要有LoginController。
先看LoginController中的 /login 登录功能的实现。
@ApiOperation(value = "登录之后返回token")
@PostMapping("/login")
public RespBean login(@RequestBody AdminLoginParam adminLoginParam, HttpServletRequest request){
return adminService.login(adminLoginParam.getUsername(), adminLoginParam.getPassword(), adminLoginParam.getCode(), request);
}
RespBean是公共返回对象,里面定义了code、message和obj(Object对象)。AdminLoginParam是前端传来的登录参数,包括用户名、密码和验证码。HttpServletRequest request是什么?
再看adminService中的login方法。
//登录之后返回token
@Override
public RespBean login(String username, String password, String code, HttpServletRequest request) {
String captcha=(String) request.getSession().getAttribute("captcha");
if (StringUtils.isEmpty(code) || !captcha.equalsIgnoreCase(code)){
return RespBean.error("验证码输入错误,请重新输入");
}
UserDetails userDetails=userDetailsService.loadUserByUsername(username);
if (userDetails==null || !passwordEncoder.matches(password, userDetails.getPassword())){
return RespBean.error("用户名或密码不正确");
}
if (!userDetails.isEnabled()){
return RespBean.error("账号被禁用,请联系管理员!");
}
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken=new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
String token = jwtTokenUtil.generateToken(userDetails);
Map<String, String> tokenMap=new HashMap<>();
tokenMap.put("token", token);
tokenMap.put("tokenHead", tokenHead);
return RespBean.success("登录成功", tokenMap);
}
该方法是先从request中取出验证码,和用户输入的验证码(code)进行对比。然后使用userDetailsService根据username获取userDetails,即用户信息。如果获取不到,或者输入的password和userDetails中的password不同,则返回错误信息。
校验通过后,根据userDetails和调用getAuthorities()方法(用来获取此用户所拥有的权限),生成authenticationToken(和jwt生成的token有什么不同?),并设置到SecurityContextHolder的Authentication中。
然后使用jwtTokenUtil工具类(之后会介绍),根据userDetails生成token,并返回给前端,表示登录成功。
再看看退出登录功能。
@ApiOperation(value = "退出登录")
@PostMapping("/logout")
public RespBean logout(){
return RespBean.success("注销成功");
}
直接返回退出成功的消息。因为这一部分主要是前端来做的。
在登录之后,后端会生成一个token令牌返回给前端,前端每次调用后端接口时都会携带这个token,当执行退出登录后,前端把这个token删除掉,此时如果前端再次请求后端接口,由于Spring Security有拦截器去校验token,发现token非法会拦截请求,这样就实现了退出登录的功能。
LoginController中还有一个获取当前登录用户信息的方法。
@ApiOperation(value = "获取当前登录用户的信息")
@GetMapping("/admin/info")
public Admin getAdminInfo(Principal principal){
if (principal==null){
return null;
}
String username = principal.getName();
Admin admin=adminService.getAdminByUserName(username);
admin.setPassword(null);
admin.setRoles(adminService.getRoles(admin.getId()));
return admin;
}
这个方法的关键是传入的principal,这个principal是由框架自动赋值传入的(不是先@Autowired),应该只能在controller中使用。(自动赋值传入这件事总感觉很不可思议。。)
以上是LoginController中的内容。用到了jwtTokenUtil工具类和访问拦截,因为访问拦截是Spring Security框架做的,因此没有显式地出现在上述代码中。下面介绍一下。
首先是jwtTokenUtil工具类,这个工具类实现了几种方法:1、根据用户信息(userDetails)生成token;2、从token中获取登录用户名;3、验证token是否有效(token是否过期;token中的username是否与userDetails中的username一致);4、判断token是否可以被刷新;5、刷新token。
先看一下SecurityConfig类,这个是Spring Security的配置类,继承了WebSecurityConfigurerAdapter。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private IAdminService adminService;
@Autowired
private RestAuthorizationEntryPoint restAuthorizationEntryPoint;
@Autowired
private RestfulAccessDeniedHandler restfulAccessDeniedHandler;
@Autowired
private CustomFilter customFilter;
@Autowired
private CustomUrlDecisionManager customUrlDecisionManager;
@Override
@Bean
public UserDetailsService userDetailsService(){
。。。
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
。。。
}
@Override
protected void configure(HttpSecurity http) throws Exception {
。。。
}
@Override
public void configure(WebSecurity web) throws Exception {
。。。
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter(){
return new JwtAuthenticationTokenFilter();
}
}
@Override
@Bean
public UserDetailsService userDetailsService(){
return username -> {
Admin admin = adminService.getAdminByUserName(username);
if (admin!=null){
admin.setRoles(adminService.getRoles(admin.getId()));
return admin;
}
throw new UsernameNotFoundException("用户名或密码不正确");
};
}
UserDetailsService接口和UserDetails是Spring Security提供给我们的,里面只有一个方法UserDetails loadUserByUsername(String str),因此这里用了lambda表达式来实现这一方法,最后返回admin(Admin实现了UserDetails)。然后注入到容器中,方便其它地方使用。注意,userDetailsService里已经查找了该用户的roles并赋值,用于权限控制(getAuthorities())。
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("t_admin")
@ApiModel(value="Admin对象", description="")
public class Admin implements Serializable, UserDetails {
。。。
@Override
@JsonDeserialize(using = CustomAuthorityDeserializer.class)
public Collection<? extends GrantedAuthority> getAuthorities() {
List<SimpleGrantedAuthority> authorities=roles
.stream()
.map(role -> new SimpleGrantedAuthority(role.getName()))
.collect(Collectors.toList());
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return enabled;
}
}
上面的Admin类实现了UserDetails接口,getAuthorities()方法用于返回此用户所拥有的权限。这里调用了CustomAuthorityDeserializer反序列化,和登录功能无关,是后面实现的其它功能用到的。
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService()).passwordEncoder(passwordEncoder());
}
这个是SecurityConfig下的configure方法,里面传入了AuthenticationManagerBuilder,这个方法的作用应该是指定所使用的userDetailsService和passwordEncoder(因为这两个都已经重新配置过了)。
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers(
"/login",
"/logout",
"/css/**",
"/js/**",
"/index.html",
"favicon.ico",
"/doc.html",
"/webjars/**",
"/swagger-resources/**",
"/v2/api-docs/**",
"/captcha",
"/ws/**"
);
}
这个同样是SecurityConfig下的configure方法,传入了WebSecurity,用来配置对哪些访问路径忽略拦截。
@Override
protected void configure(HttpSecurity http) throws Exception {
//使用jwt,不需要csrf
http.csrf()
.disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.anyRequest()
.authenticated()
//动态权限配置
.withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(O o) {
o.setAccessDecisionManager(customUrlDecisionManager);
o.setSecurityMetadataSource(customFilter);
return o;
}
})
.and()
.headers()
.cacheControl();
http.addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class);
http.exceptionHandling()
.accessDeniedHandler(restfulAccessDeniedHandler)
.authenticationEntryPoint(restAuthorizationEntryPoint);
}
这个同样是SecurityConfig下的configure方法,传入了HttpSecurity,进行了相关配置。配置主要包括:1、禁用csrf;2、基于token,不需要session,不创建HttpSession,不使用HttpSession来获取SecurityContext;3、所有请求都要求认证;4、动态权限配置,设置了自定义的AccessDecisionManager(访问决策管理器)和SecurityMetadataSource(安全元数据源);5、禁用缓存;6、在UsernamePasswordAuthenticationFilter前配置jwtAuthenticationTokenFilter(这个是自己定义的filter,后面有讲);7、配置异常控制,accessDeniedHandler用来控制认证过的用户访问无权限资源时的异常,authenticationEntryPoint用来控制匿名用户访问无权限资源时的异常,这里说的异常应该是Spring Security拦截到非法请求后执行的方法。
下面是自定义的异常控制。
//当访问接口没有权限时,自定义返回结果
@Component
public class RestfulAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
httpServletResponse.setCharacterEncoding("UTF-8");
httpServletResponse.setContentType("application/json");
PrintWriter out = httpServletResponse.getWriter();
RespBean bean=RespBean.error("权限不足,请联系管理员");
bean.setCode(403);
out.write(new ObjectMapper().writeValueAsString(bean));
out.flush();
out.close();
}
}
//当未登录或token失效访问接口时,自定义的返回结果
@Component
public class RestAuthorizationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
httpServletResponse.setCharacterEncoding("UTF-8");
httpServletResponse.setContentType("application/json");
PrintWriter out = httpServletResponse.getWriter();
RespBean bean=RespBean.error("尚未登录,请先去登录");
bean.setCode(401);
out.write(new ObjectMapper().writeValueAsString(bean));
out.flush();
out.close();
}
}
下面是JwtAuthenticationTokenFilter,继承了OncePerRequestFilter。
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Value("${jwt.tokenHeader}")
private String tokenHeader;
@Value("${jwt.tokenHead}")
private String tokenHead;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
String authHeader = httpServletRequest.getHeader(tokenHeader);
if (authHeader!=null && authHeader.startsWith(tokenHead)){
String authToken = authHeader.substring(tokenHead.length());
String username = jwtTokenUtil.getUserNameFromToken(authToken);
if (username!=null && SecurityContextHolder.getContext().getAuthentication()==null){
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtTokenUtil.validateToken(authToken, userDetails)){
UsernamePasswordAuthenticationToken authenticationToken=new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
}
filterChain.doFilter(httpServletRequest, httpServletResponse);
}
}
jwt:
tokenHeader: Authorization
secret: yeb-secret
expiration: 604800
tokenHead: Bearer
前端的请求头里包含key-value对,其中一个key就是Authorization,value就是Bearer+空格+jwt令牌,用来进行权限认证。先从HttpServletRequest中拿到Authorization对应的value,是个字符串,把jwt令牌截取出来,使用jwtTokenUtil工具类根据token获取username。这个filter的作用是如果token对应的username确实存在,而且token没过期,但没登录(Spring Security上下文里找不到),就让它先登录再放行。(既然还没登录,前端哪来的token?设置这个filter应该是为了方便测试接口)
//权限控制 根据请求的url得出请求所需的角色
@Component
public class CustomFilter implements FilterInvocationSecurityMetadataSource {
@Autowired
private IMenuService menuService;
AntPathMatcher antPathMatcher=new AntPathMatcher();
@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
String requestUrl = ((FilterInvocation) object).getRequestUrl();
List<Menu> menus=menuService.getMenusWithRole();
for (Menu menu:menus) {
if (antPathMatcher.match(menu.getUrl(), requestUrl)){
String[] str=menu.getRoles().stream().map(Role::getName).toArray(String[]::new);
return SecurityConfig.createList(str);
}
}
return SecurityConfig.createList("ROLE_LOGIN");
}
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
return null;
}
@Override
public boolean supports(Class<?> aClass) {
return false;
}
}
//权限控制 判断用户角色
@Component
public class CustomUrlDecisionManager implements AccessDecisionManager {
@Override
public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
for (ConfigAttribute configAttribute : configAttributes) {
//当前url所需角色
String needRole=configAttribute.getAttribute();
//判断角色是否为登录即可访问的角色,此角色在CustomFilter中设置
if (needRole.equals("ROLE_LOGIN")){
//判断是否登录
if (authentication instanceof AnonymousAuthenticationToken){
throw new AccessDeniedException("尚未登录,请登录");
}else {
return;
}
}
//判断用户角色是否为url所需角色
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
for (GrantedAuthority authority : authorities){
if (authority.getAuthority().equals(needRole)){
return;
}
}
}
throw new AccessDeniedException("权限不足,请联系管理员");
}
@Override
public boolean supports(ConfigAttribute configAttribute) {
return false;
}
@Override
public boolean supports(Class<?> aClass) {
return false;
}
}
SecurityMetadataSource(安全元数据源)的作用是根据请求的url和哪个menu匹配,进而在t_menu_role表中查出这个menu对应哪些角色,然后把这些角色名作为权限设置到Collection_ConfigAttribute中。简单来说就是设置本次url请求所需要的具体权限。
AccessDecisionManager(访问决策管理器)的作用是判断当前用户的权限列表(这个当前用户的权限列表是在登录时随着userDetails设置到Spring Security上下文中的)中是否有本次url请求所需要的权限。简单来说就是访问权限决策。