自定义Spring Security Starter组件

Posted by Kaka Blog on December 8, 2020

前言

认证授权功能几乎是每个系统必备功能,本文实现基于Spring Security安全认证开发Maven组件,方便以后项目添加依赖即可使用。

创建Maven项目

环境

  • Spring Boot:2.1.17
  • Spring Cloud:Finchley.RELEASE

pom.xml文件

<groupId>com.fang</groupId>
<artifactId>auth-server</artifactId>
<version>1.0-SNAPSHOT</version>

<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
    <lombok.version>1.16.20</lombok.version>
    <fastjson.version>1.2.74</fastjson.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>${fastjson.version}</version>
    </dependency>
</dependencies>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>Finchley.RELEASE</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

实现认证功能

1、登录成功处理,登录成功后保存SecurityContext到Redis。

@Component
public class RestAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Autowired
    private RedisTemplate<String, Serializable> redisTemplate;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        SecurityContextHolder.getContext().setAuthentication(authentication);
        String token = UUID.randomUUID().toString();
        saveTokenToRedis(authentication.getPrincipal().toString(), token);
        String ipAddress = IpUtil.getIpAddress(request);
        String userAgent = BrowserUtil.getUserAgent(request);
        // 客户端IP和UserAgent拼接后AES加密,防止cookie被窃取
        String encryptToken = AesUtil.encrypt(token, ipAddress + userAgent);
        response.setCharacterEncoding("utf-8");
        response.setContentType("application/json; charset=utf-8");
        Map<String, Object> result = new HashMap<>(3);
        result.put("code", 200);
        result.put("message", "success");
        result.put("data", encryptToken);
        response.getWriter().print(JSON.toJSONString(result));
    }

    private void saveTokenToRedis(String username, String token) {
        String oldToken = (String) redisTemplate.opsForValue().get(username);
        if (!StringUtils.isEmpty(oldToken)) {
            redisTemplate.opsForHash().delete(oldToken, Constants.KEY_CONTEXT);
        }
        redisTemplate.opsForValue().set(username, token);
        redisTemplate.opsForHash().put(token, Constants.KEY_CONTEXT, SecurityContextHolder.getContext());
        redisTemplate.expire(token, Constants.EXPIRE_TOKEN, TimeUnit.SECONDS);
    }
}

2、登录失败处理,直接返回异常信息。

@Component
public class RestAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                        AuthenticationException e) throws IOException, ServletException {
        response.setCharacterEncoding("utf-8");
        response.setContentType("application/json; charset=utf-8");
        Map<String, Object> result = new HashMap<>(2);
        result.put("code", 405);
        result.put("message", e.getMessage());
        response.getWriter().print(JSON.toJSONString(result));
    }
}

3、退出登录处理,清理Redis。

@Component
public class RestLogoutSuccessHandler implements LogoutSuccessHandler {
    private static final Log logger = LogFactory.getLog(RestLogoutSuccessHandler.class);
    @Autowired
    private RedisTemplate<String, Serializable> redisTemplate;

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
                                Authentication authentication) throws IOException, ServletException {
        String token = RedisSecurityContextRepository.extractToken(request);
        System.out.println("logout success: " + token);
        if (!StringUtils.isEmpty(token)) {
            CookieUtil.delCookie(request, response, Constants.HEADER_AUTHENTICATION);
            redisTemplate.delete(token);
        }
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        new SecurityContextLogoutHandler().logout(request, response, auth);
        response.setHeader(Constants.HEADER_AUTHENTICATION, null);
        response.setCharacterEncoding("utf-8");
        response.setContentType("application/json; charset=utf-8");
        Map<String, Object> result = new HashMap<>(2);
        result.put("code", 200);
        result.put("message", "success");
        response.getWriter().print(JSON.toJSONString(result));
    }
}

4、匿名用户访问权限资源处理,返回401。

@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
        response.setCharacterEncoding("utf-8");
        response.setContentType("application/json; charset=utf-8");
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        Map<String, Object> result = new HashMap<>(2);
        result.put("code", 401);
        result.put("message", e.getMessage());
        response.getWriter().print(JSON.toJSONString(result));
    }
}

5、登录用户没有权限处理,返回403。

@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
        response.setCharacterEncoding("utf-8");
        response.setContentType("application/json; charset=utf-8");
        response.setStatus(HttpStatus.FORBIDDEN.value());
        Map<String, Object> result = new HashMap<>(2);
        result.put("code", 403);
        result.put("message", e.getMessage());
        response.getWriter().print(JSON.toJSONString(result));
    }
}

实现权限控制功能

1、定义url权限配置文件。

@ConfigurationProperties(prefix = "auth.permission")
public class UrlRoleProperites {
    public List<UrlRoleDefinition> getUrlRoles() {
        return urlRoles;
    }

    public void setUrlRoles(List<UrlRoleDefinition> urlRoles) {
        this.urlRoles = urlRoles;
    }

    private List<UrlRoleDefinition> urlRoles = new ArrayList<>();

    public static class UrlRoleDefinition {
        private String url;
        private String[] roles;

        public String getUrl() {
            return url;
        }

        public void setUrl(String url) {
            this.url = url;
        }

        public String[] getRoles() {
            return roles;
        }

        public void setRoles(String[] roles) {
            this.roles = roles;
        }
    }

}

2、权限过滤,匹配需要权限的url,不需要权限的url直接返回null。

@Component
public class UrlFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {

    @Autowired
    private UrlRoleProperites urlRoleProperites;
    private AntPathMatcher antPathMatcher = new AntPathMatcher();
    @Value("${auth.authenticated.patterns}")
    private String[] permitPatterns;

    /**
     * 返回本次访问需要的权限,可以有多个权限。
     * 如果没有匹配的url直接返回null,也就是没有配置权限的url默认都为白名单
     * @param o
     * @return
     * @throws IllegalArgumentException
     */
    @Override
    public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
        String requestUrl = ((FilterInvocation) o).getRequestUrl();
        for (UrlRoleProperites.UrlRoleDefinition item : urlRoleProperites.getUrlRoles()) {
            if (antPathMatcher.match(item.getUrl(), requestUrl) && item.getRoles().length > 0) {
                System.out.println("当前访问的路径:" + requestUrl + ",需要的权限:" + JSON.toJSONString(item.getRoles()));
                return SecurityConfig.createList(item.getRoles());
            }
        }
        for (String url : permitPatterns) {
            if (antPathMatcher.match(url, requestUrl)) {
                return SecurityConfig.createList("ROLE_LOGIN");
            }
        }
        return null;
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    /**
     * 告知调用者当前SecurityMetadataSource是否支持此类安全对象,只有支持的时候,才能对这类安全对象调用getAttributes方法。
     * @param aClass
     * @return
     */
    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }
}
  • permitPatterns属性:定义需要认证的路径

3、权限匹配,ROLE_LOGIN表示需要登录,能够到循环里表示已经认证过,直接返回即可。

@Component
public class UrlAccessDecisionManager implements AccessDecisionManager {
    @Override
    public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, InsufficientAuthenticationException {
        System.out.println("JSON.toJSONString(authentication) = " + JSON.toJSONString(authentication));
        if (authentication == null || authentication.getName().equals("anonymousUser")) {
            throw new AccessDeniedException("没有访问权限");
        }
        Iterator<ConfigAttribute> iterator = collection.iterator();
        while (iterator.hasNext()) {
            ConfigAttribute configAttribute = iterator.next();
            String role = configAttribute.getAttribute();
            if ("ROLE_LOGIN".equals(role)) {
                return;
            }
            Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
            for (GrantedAuthority authority : authorities) {
                if (authority.getAuthority().equals(role)) {
                    return;
                }
            }
        }
        throw new AccessDeniedException("权限不足");
    }

    @Override
    public boolean supports(ConfigAttribute configAttribute) {
        return true;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }
}

安全认证配置

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private PasswordEncoder passwordEncoder;
    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private AuthenticationSuccessHandler authenticationSuccessHandler;
    @Autowired
    private AuthenticationFailureHandler authenticationFailureHandler;
    @Autowired
    private LogoutSuccessHandler logoutSuccessHandler;
    @Autowired
    private AuthenticationEntryPoint authenticationEntryPoint;
    @Autowired
    private AccessDeniedHandler accessDeniedHandler;
    @Autowired
    private AccessDecisionManager accessDecisionManager;
    @Autowired
    private FilterInvocationSecurityMetadataSource filterInvocationSecurityMetadataSource;

    @Autowired
    private RedisTemplate<String, Serializable> redisTemplate;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Value("${auth.authenticated.patterns}")
    private String[] permitPatterns;

    public CustomAuthenticationProvider getAuthenticationProvider() {
        System.out.println("userDetailsService = " + userDetailsService);
        CustomAuthenticationProvider authenticationProvider = new CustomAuthenticationProvider(stringRedisTemplate);
        authenticationProvider.setUserDetailsService(userDetailsService);
        authenticationProvider.setPasswordEncoder(passwordEncoder);
        return authenticationProvider;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 不能在CustomAuthenticationProvider添加@Component注解,需要通过new方法然后注入userDetailsService
        auth.authenticationProvider(getAuthenticationProvider());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //禁用CSRF
        http.csrf().disable().cors();

        http.authorizeRequests()
//                .antMatchers(permitPatterns).authenticated() // 和withObjectPostProcessor一起没效果
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O o) {
                        o.setSecurityMetadataSource(filterInvocationSecurityMetadataSource);
                        o.setAccessDecisionManager(accessDecisionManager);
                        return o;
                    }
                }) //设置后置处理程序对象
                .anyRequest().permitAll().and()
                .formLogin().loginProcessingUrl("/oauth/token").permitAll()
                .failureHandler(authenticationFailureHandler)
                .successHandler(authenticationSuccessHandler).and()
                .logout()
                .logoutSuccessHandler(logoutSuccessHandler).and()
                .exceptionHandling().accessDeniedHandler(accessDeniedHandler)
                //没有登录异常
                .authenticationEntryPoint(authenticationEntryPoint).and()
                //禁用session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.NEVER);
        http.securityContext().securityContextRepository(getSecurityContextRepository());
    }

    SecurityContextRepository getSecurityContextRepository() {
        return new RedisSecurityContextRepository(redisTemplate);
    }

    @Bean
    public UrlRoleProperites urlRoleProperites() {
        return new UrlRoleProperites();
    }
}
  • 不需要权限控制去掉withObjectPostProcessor就可以,加上.antMatchers(permitPatterns).authenticated()
  • 为了实现.antMatchers(permitPatterns).authenticated()功能,通过在UrlFilterInvocationSecurityMetadataSource实现路径权限判断。
  • RedisSecurityContextRepository类、RedisConfig类和一些工具类这里省略。

到这里就可以使用了。

实现多次登录失败暂停登录功能

1、需要自定义AuthenticationProvider,在登录前判断登录失败次数,认证失败更新登录失败次数。

public class CustomAuthenticationProvider extends DaoAuthenticationProvider {
    protected final Log logger = LogFactory.getLog(this.getClass());
    private StringRedisTemplate redisTemplate;

    public CustomAuthenticationProvider(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    public void setPasswordEncoder(PasswordEncoder passwordEncoder) {
        super.setPasswordEncoder(passwordEncoder);
    }

    @Override
    public void setUserDetailsService(UserDetailsService userDetailsService) {
        super.setUserDetailsService(userDetailsService);
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        try {
            logger.info("auth user: " + authentication.getName());
            // 登录前验证
            attempAuthenticate(authentication.getName());
            // 调用上层验证逻辑
            Authentication auth = super.authenticate(authentication);
            // 如果验证通过登录成功则重置尝试次数, 否则抛出异常
            resetFailAttemps(authentication.getName());
            return auth;
        }
        catch (BadCredentialsException e) {
            // 如果验证不通过,则更新尝试次数
            updateFailAttemps(authentication.getName());
            throw e;
        }
    }

    private void attempAuthenticate(String name) {
        Serializable serializable = redisTemplate.opsForValue().get(name);
        logger.info("get redis value: " + serializable);
        if (serializable == null) {
            return;
        }
        try {
            int times = Integer.parseInt(serializable.toString());
            if (times > Constants.MAX_LOGIN_TIMES) {
                throw new LockedException("登录失败次数超过" + Constants.MAX_LOGIN_TIMES
                        + "次,请" + Constants.EXPIRE_LOGIN_FAIL / 60 + "分钟后再试");
            }
        } catch (NumberFormatException e) {
            e.printStackTrace();
        }
    }

    private void updateFailAttemps(String name) {
        redisTemplate.opsForValue().setIfAbsent(name, "1", Constants.EXPIRE_LOGIN_FAIL, TimeUnit.SECONDS);
        redisTemplate.opsForValue().increment(name);
    }

    private void resetFailAttemps(String name) {
        redisTemplate.delete(name);
    }
}
  • 注意用到redis的increment功能,需要使用StringRedisTemplate
  • 不能在CustomAuthenticationProvider添加@Component注解,需要通过new方法然后注入userDetailsService。

使用

1、创建Spring Boot项目,添加依赖。

<dependency>
    <groupId>com.fang</groupId>
    <artifactId>auth-server</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>

2、修改配置文件

auth.authenticated.patterns=/auth/**

auth.permission.url-roles[0].url=/auth/test
auth.permission.url-roles[0].roles=ROLE_admin,ROLE_user
  • auth.authenticated.patterns:定义哪些路径需要登录
  • auth.permission.url-roles:定义路径和对应需要什么权限,路径可以是list,路径的权限是list。

3、根据自己的业务实现UserDetailsService接口,正常是查询数据库用户信息。

@Slf4j
@Service
public class LoginService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        log.info("username: " + s);
        Set<SimpleGrantedAuthority> simpleGrantedAuthoritySet = new HashSet<>();
        if ("admin".equals(s)) {
            simpleGrantedAuthoritySet.add(new SimpleGrantedAuthority("ROLE_admin"));
            return new User("admin", "$2a$10$oEZnA0YTINrwxysn.e2HsuGIPSbtd9EJzj6n.6PvYpFJ.FlGdmNIO", simpleGrantedAuthoritySet);
        }
        simpleGrantedAuthoritySet.add(new SimpleGrantedAuthority("ROLE_user"));
        return new User("111", "eowYHBL0OwfcyVQra4EY7gQW50f6CbXNnp8V2pBhCrtIffccZFlYAmQBzHDGbFmq", simpleGrantedAuthoritySet);
    }
}

4、自定义密码加密算法,加上AES解密是为了前端发送密码时可以用AES加密,不通过明文传输。

@Component
public class CustomBCryptPasswordEncoder extends BCryptPasswordEncoder {
    @Override
    public String encode(CharSequence rawPassword) {
        String decrypt = null;
        try {
            decrypt = AesUtil.decrypt(rawPassword.toString(), "fang");
        } catch (Exception e) {
            e.printStackTrace();
        }
        if (decrypt == null) {
            return super.encode(rawPassword);
        }
        return super.encode(decrypt);
    }

    @Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        String decrypt = null;
        try {
            decrypt = AesUtil.decrypt(rawPassword.toString(), "fang");
        } catch (Exception e) {
            e.printStackTrace();
        }
        if (decrypt == null) {
            return super.matches(rawPassword, encodedPassword);
        }
        return super.matches(decrypt, encodedPassword);
    }
}

测试

1、访问http://localhost:8080/auth/str,返回:

{
    "message": "Full authentication is required to access this resource",
    "code": 401
}

2、访问http://localhost:8080/user/str,返回:

{
    "code": 0,
    "message": "成功",
    "data": "anonymousUser"
}

3、登录http://localhost:8080/oauth/token,POST方式,username: admin1,password:5Ov4vAoTgzCTAgUnAEo_AA==

返回:

{
    "message": "用户名或密码错误",
    "code": 405
}

登录5次后返回:

{
    "message": "登录失败次数超过5次,请5分钟后再试",
    "code": 405
}

4、登录http://localhost:8080/oauth/token,POST方式,username: admin,password:5Ov4vAoTgzCTAgUnAEo_AA==

返回:

{
    "message": "success",
    "data": "NEcXUcPjPOwEJhb-u-oyCfd4OMx-8TP4uo1NAR62XK-6MtYmAXwhNxbPmktZeylk",
    "code": 200
}

5、访问http://localhost:8080/auth/str,设置Header:”Authorization”: “NEcXUcPjPOwEJhb-u-oyCfd4OMx-8TP4uo1NAR62XK-6MtYmAXwhNxbPmktZeylk”

返回:

{
    "code": 0,
    "message": "成功",
    "data": "org.springframework.security.core.userdetails.User@586034f: Username: admin; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ROLE_admin"
}

6、访问http://localhost:8080/auth/test,设置Header:”Authorization”: “NEcXUcPjPOwEJhb-u-oyCfd4OMx-8TP4uo1NAR62XK-6MtYmAXwhNxbPmktZeylk”,返回:

{
    "code": 0,
    "message": "成功",
    "data": {
        "data": "[{\"authority\":\"ROLE_admin\"}]"
    }
}

7、修改配置文件:auth.permission.url-roles[0].roles=ROLE_user,重启服务,访问http://localhost:8080/auth/test,返回:

{
    "message": "权限不足",
    "code": 403
}
  • 控制台打印:当前访问的路径:/auth/test,需要的权限:["ROLE_user"]

总结

  • AbstractAuthenticationProcessingFilter处理登录:先根据发送的参数封装成Token,再获取用户详细信息,然后认证是否通过。
  • AbstractSecurityInterceptor安全控制:处理请求前先获取安全资源所需权限,判断当前用户是否有权限访问资源。