# uaa-demo **Repository Path**: chnyang/uaa-demo ## Basic Information - **Project Name**: uaa-demo - **Description**: 基于Spring Security认证授权实现。优雅集成各种第三方登录方式 - **Primary Language**: Java - **License**: Apache-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 2 - **Forks**: 9 - **Created**: 2021-01-22 - **Last Updated**: 2022-04-15 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 基于Spring Security认证授权实现 * SpringMVC * Spring Security 认证授权框架 * Mybatis ORM框架 * Redis session缓存 ## 上手运行 [数据库文件](doc/uaa-demo.sql) [postman测试接口](doc/uaa-demo.postman_collection.json) 修改配置文件中数据库和redis配置即可运行。具体接口见postman测试文件 ![postman](doc/postman.png) ## 服务安全配置 配置登录、登出、认证授权异常处理、登录逻辑。以及各路经权限级别 ```java @EnableWebSecurity public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Resource private IntegrationUserDetailService integrationUserDetailService; @Resource private IntegrationAuthenticationFilter integrationAuthenticationFilter; @Override protected void configure(HttpSecurity http) throws Exception { //登录处理url,与拦截器一致 String loginUrl = IntegrationAuthenticationFilter.LOGIN_PATH; http .csrf().disable()//关闭自带的csrf验证,方便直接使用接口调用 .userDetailsService(integrationUserDetailService)//自定义userDetailService .addFilterBefore(integrationAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)//自定义集成登录拦截器 .formLogin() .loginProcessingUrl(loginUrl) .successHandler(new LoginSuccessHandler())//登录成功 .failureHandler(new LoginFailHandler())//失败返回处理 .and() .exceptionHandling() .accessDeniedHandler(new MyAccessDeniedHandler())//无权限处理403 .authenticationEntryPoint(new MyAuthenticationEntryPoint())//认证失败处理401 .and() .authorizeRequests() // 授权配置 .antMatchers(HttpMethod.OPTIONS).permitAll() .antMatchers( loginUrl,//登录url "/code/image",// 图片验证码接口 "/mylogout"//自定义登出接口,允许匿名访问(登录失效用户登出情况) ).permitAll() // 无需认证的请求路径 .antMatchers(HttpMethod.POST,"/login","login").permitAll() .anyRequest().authenticated()// 其他请求全部验证 .and() .logout() .logoutUrl("/signout") // 退出登录的url .logoutSuccessHandler(new LogoutSuccessHandler()) // 退出成功处理 .deleteCookies("JSESSIONID") // 删除名为'JSESSIONID'的cookie(cookie模式用) ; } } ``` ### 登录处理 Spring security的默认配置,设置loginProcessUrl即可。IntegrationAuthenticationFilter集成多种形式登录用后面说。 #### 登录成功 登录成功后回调 ```java public class LoginSuccessHandler extends BaseLogger implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException { log.info("login success"); httpServletResponse.setContentType("application/json;charset=utf-8"); PrintWriter writer = httpServletResponse.getWriter(); ResultData resultData = ResultData.success("登录成功!"); writer.write(JsonBeanConvertUtils.beanToJson(resultData)); writer.flush(); writer.close(); } } ``` #### 登录失败 用户登录失败后回调 ```java public class LoginFailHandler extends BaseLogger implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { log.warn("auth failed:"+exception.getMessage()); response.setContentType("application/json;charset=utf-8"); PrintWriter writer = response.getWriter(); //FIXME 这里可以解析不同的AuthenticationException,自定义返回结果 ResultData resultData = ResultData.fail("登录失败:"+exception.getMessage()); writer.write(JsonBeanConvertUtils.beanToJson(resultData)); writer.flush(); writer.close(); } } ``` #### 认证失败 未登录用户访问受保护接口 ```java public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint { private Logger log = LoggerFactory.getLogger(this.getClass()); @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { log.warn("auth failed:{}", authException.getMessage()); response.setContentType("application/json;charset=utf-8"); response.setStatus(HttpStatus.UNAUTHORIZED.value()); PrintWriter writer = response.getWriter(); ResultData resultData = ResultData.fail("认证失败:"+authException.getMessage()); writer.write(JsonBeanConvertUtils.beanToJson(resultData)); writer.flush(); writer.close(); } } ``` #### 访问受限 已登录用户无权限的情况 ```java public class MyAccessDeniedHandler extends BaseLogger implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { log.warn("access denied:"+accessDeniedException.getMessage()); response.setContentType("application/json;charset=utf-8"); response.setStatus(HttpStatus.FORBIDDEN.value()); PrintWriter writer = response.getWriter(); ResultData resultData = ResultData.fail("访问受限:"+accessDeniedException.getMessage()); writer.write(JsonBeanConvertUtils.beanToJson(resultData)); writer.flush(); writer.close(); } } ``` ### 退出登录 配置logoutUrl,无论用户是否登录都可以通过这个接口退出登录 ```java public class LogoutSuccessHandler extends BaseLogger implements org.springframework.security.web.authentication.logout.LogoutSuccessHandler { @Override public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { log.info("logout success"); response.setContentType("application/json;charset=utf-8"); PrintWriter writer = response.getWriter(); ResultData resultData = ResultData.success("退出成功!"); writer.write(JsonBeanConvertUtils.beanToJson(resultData)); writer.flush(); writer.close(); } } ``` ## 用户权限 ### 加载用户信息 简单做法:实现UserDetailsService接口并将bean注入给HttpSecurity即可(后面集成登录时用了另一种方法) ```java @Component public class MyUserDetailService implements UserDetailsService { @Resource private SysUserService sysUserService; @Resource private SysRoleService sysRoleService; @Resource private SysResourceService sysResourceService; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser sysUser = sysUserService.findByUsername(username); if(sysUser == null){ throw new UsernameNotFoundException(username); } Set roleCodes = sysRoleService.listRoleCodeByUser(sysUser.getId()); Set resourceCodes = sysResourceService.listResourceCodeByUser(sysUser.getId()); return MyUserDetail.build(sysUser, roleCodes, resourceCodes); } } ``` ### 方法级权限注解 启动类开启权限注解 ```java @EnableGlobalMethodSecurity(prePostEnabled = true) @SpringBootApplication public class UaaDemoApplication { public static void main(String[] args) { SpringApplication.run(UaaDemoApplication.class, args); } } ``` Controller中加权限注解,无权限则跳转到MyAccessDeniedHandler处理 ```java @RestController public class HelloController { //administrator角色允许访问 @PreAuthorize("hasRole('administrator')") @RequestMapping("hello") public String hello(){ return "hello world"; } //add权限允许访问 @PreAuthorize("hasAnyAuthority('add')") @RequestMapping("hello1") public ResultData hello1(){ return ResultData.success(); } } ``` ## 集成多种类型的登录 常见的登录方式有: * 用户名密码 * 用户名密码+图形验证码 * 用户名密码+短信验证码 * 各种第三方登录(第三方OAUTH2.0) 扩展原有框架,支持各种登录方式都很繁琐,对代码侵入性比较强。参考[Spring Security OAuth2 优雅的集成短信验证码登录以及第三方登录](https://segmentfault.com/a/1190000014371789)采用自定义拦截器+ThreadLocal的方式实现对各种登录方式的支持。 核心思想是在原来的UsernamePasswordAuthenticationFilter拦截器之前加一层自定义拦截,根据不同的登录方式来认证(调用自定义认证方式的Authenticator类)。认证成功后,将密码强制覆盖掉以便于通过用户名密码拦截器。 ### 定义拦截器拦截登录请求 在原来的UsernamePasswordAuthenticationFilter拦截器之前加一层自定义拦截 ```java @Component public class IntegrationAuthenticationFilter extends GenericFilterBean implements ApplicationContextAware { private static final String AUTH_TYPE_PARM_NAME = "authType"; /**这里要与配置中登录接口一致*/ public static final String LOGIN_PATH = "/login"; private RequestMatcher requestMatcher; private Collection authenticators; private ApplicationContext applicationContext; public IntegrationAuthenticationFilter(){ this.requestMatcher = new AntPathRequestMatcher(LOGIN_PATH); } @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) servletRequest; HttpServletResponse response = (HttpServletResponse) servletResponse; //登录类型 String authType = request.getParameter(AUTH_TYPE_PARM_NAME); //匹配url且登录类型不为空 if(requestMatcher.matches(request) && StringUtils.isNotEmpty(authType)){ //设置集成登录信息 IntegrationAuthentication integrationAuthentication = new IntegrationAuthentication(); integrationAuthentication.setAuthType(authType); integrationAuthentication.setParameterMap(request.getParameterMap()); IntegrationAuthenticationContext.set(integrationAuthentication); try{ //预处理 this.prepare(integrationAuthentication, request); //重写request, 覆盖密码. 表示不校验密码 if(integrationAuthentication.getPassword()!=null){ request = new HttpServletRequestWrapper(request) { @Override public String getParameter(String name) { if("password".equals(name)){ return integrationAuthentication.getPassword(); } return super.getParameter(name); } @Override public Map getParameterMap() { HashMap newMap = new HashMap<>(); newMap.putAll(super.getParameterMap()); newMap.put("password",new String[]{integrationAuthentication.getPassword()}) ; return Collections.unmodifiableMap(newMap); } @Override public String[] getParameterValues(String name) { if("password".equals(name)){ return new String[]{integrationAuthentication.getPassword()}; } return super.getParameterValues(name); } }; } filterChain.doFilter(request,response); //后置处理 this.complete(integrationAuthentication); }finally { //清除集成登录信息 IntegrationAuthenticationContext.clear(); } }else{ filterChain.doFilter(servletRequest, servletResponse); } } private void prepare(IntegrationAuthentication integrationAuthentication, HttpServletRequest request){ //延迟注入 FIXME 这里需要改一下 if(this.authenticators == null){ synchronized (this){ Map integrationAuthenticatorMap = applicationContext.getBeansOfType(AbstractIntegrationAuthenticator.class); if(integrationAuthenticatorMap != null){ this.authenticators = integrationAuthenticatorMap.values(); } } } if(this.authenticators == null){ this.authenticators = new ArrayList<>(); } for (AbstractIntegrationAuthenticator authenticator: authenticators) { if(authenticator.support(integrationAuthentication)){ authenticator.prepare(integrationAuthentication); } } } private void complete(IntegrationAuthentication integrationAuthentication){ for (AbstractIntegrationAuthenticator authenticator: authenticators) { if(authenticator.support(integrationAuthentication)){ authenticator.complete(integrationAuthentication); } } } @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { this.applicationContext = applicationContext; } } ``` 拦截器做了几个工作 1. 根据authType字段判断登录方式,来调用不同登录方式的AbstractIntegrationAuthenticator 2. prepare()方法,主要是调用匹配到的AbstractIntegrationAuthenticator.prepare()方法。部分登录方式对密码进行覆盖,表示不校验密码(如短信验证码登录) 3. 通过ThreadLocal传递IntegrationAuthentication 4. complete() 认证完成后,调用具体匹配到的登录方式中complete()。 回调方法目前无实现 这样,以后再增加登录方式,我们只需要增加一个AbstractIntegrationAuthenticator的实现,注册为Spring bean即可。很方便 ### AbstractIntegrationAuthenticator 具体登录方式的自定义认证器,先看一些抽象类的方法 ```java public abstract class AbstractIntegrationAuthenticator extends BaseLogger { public abstract boolean support(IntegrationAuthentication integrationAuthentication); /** * 前置流程, 主要是将参数一些预处理 * 例如如果不需要默认的密码验证,将integrationAuthentication password字段塞入任一值 * @param integrationAuthentication */ public abstract void prepare(IntegrationAuthentication integrationAuthentication); /** * 验证用户,此方法将在UserDetailService中调用 * 1、参数校验 2、验证用户登录是否合法 3、加载用户信息 * @param integrationAuthentication * @return */ public abstract MyUserDetail authenticate(IntegrationAuthentication integrationAuthentication) throws AuthenticationException; /** * 后置流程,目前无逻辑 * @param integrationAuthentication */ public abstract void complete(IntegrationAuthentication integrationAuthentication); } ``` 以短信验证码登录为例 ```java /** * 短信验证码登录认证器 * @author zhaoyd * @date 2020-11-03 22:31 */ @Component public class SmsIntegrationAuthenticator extends AbstractIntegrationAuthenticator { private final static String AUTH_TYPE = LoginType.SMS.name(); private static final String PHONE_PARM_NAME = "phone"; private static final String CODE_PARM_NAME = "code"; @Resource private SysUserService sysUserService; @Resource private SysRoleService sysRoleService; @Resource private SysResourceService sysResourceService; @Override public boolean support(IntegrationAuthentication integrationAuthentication) { return StringUtils.equalsIgnoreCase(integrationAuthentication.getAuthType(), AUTH_TYPE); } @Override public void prepare(IntegrationAuthentication integrationAuthentication) { //将验证码作为用户名密码,覆盖默认的密码验证,使得密码验证通过 //(1、将code值覆盖request中的password参数, 2、code值覆盖UserDetails中的password) String code = integrationAuthentication.getParameter(CODE_PARM_NAME); integrationAuthentication.setPassword(code); } @Override public MyUserDetail authenticate(IntegrationAuthentication integrationAuthentication) throws AuthenticationException { String phone = integrationAuthentication.getParameter(PHONE_PARM_NAME); String code = integrationAuthentication.getParameter(CODE_PARM_NAME); if(StringUtils.isBlank(phone) || StringUtils.isBlank(code)){ throw new BadCredentialsException("手机号或短信验证码不能为空"); } log.info("用户短信验证码登录:phone={},code={}",phone, code); //验证code //TODO 短信验证码验证逻辑待补充 if(!StringUtils.equalsIgnoreCase("1234", code)){ throw new SmsCodeInvalidException("短信验证码不正确"); } //查询用户 SysUser sysUser = sysUserService.findByPhone(phone); if(sysUser == null){ //注意,这里不能用UsernameNotFoundException("xxx"),此异常会被BadCredentialsException("用户名或密码错误")覆盖掉 throw new BadCredentialsException("用户手机号码不存在"); } Set roleCodes = sysRoleService.listRoleCodeByUser(sysUser.getId()); Set resourceCodes = sysResourceService.listResourceCodeByUser(sysUser.getId()); return MyUserDetail.build(sysUser, roleCodes, resourceCodes); } @Override public void complete(IntegrationAuthentication integrationAuthentication) { } } ``` 认证器做了两个动作 1. prepare方法中中设置integrationAuthentication的password。表示覆盖原来的密码参数 2. authenticate方法中,实现自定义的短信code、手机号码认证逻辑。 对比图形验证码登录。 图形验证码没有覆盖密码参数表示:在authenticate方法中校验图形验证码,在后面的UsernamePasswordAuthenticationFilter中校验密码。 ## 集成第三方登录 和前面集成短信验证码、图形验证码的方式一样。只是多了一些oauth2的逻辑。 以gitee第三方登录为例(标准oauth2授权码方式登录,微信qq也都是一样) ### 准备工作 在gitee上申请第三方应用,获得 * clientId 客户端id 标识uaa-demo这个应用 * clientSecret 客户端秘钥,后面根据授权code获取用户信息用 * 回调地址 用户在gitee上确认授权登录uaa-demo后,调用回调地址访问uaa-demo,并带上授权code ### 用户操作 这一步不需要后端处理,前端做一些跳转即可 https://gitee.com/oauth/authorize ?client_id={clientId}&redirect_uri={redirectUri}&response_type=code 在浏览器请求这个url。跳转到gitee授权登录页,授权后,跳转到回调地址。 redirectUri 我们可以在项目中实现这个接口。接收后forward到根据授权code登录的页面(这一步没有写) ### 根据授权code登录 ```java @Component public class GiteeIntegrationAuthenticator extends AbstractIntegrationAuthenticator { private final static String AUTH_TYPE = LoginType.GITEE.name(); private static final String CODE_PARM_NAME = "code"; @Resource private SysUserService sysUserService; @Resource private SysRoleService sysRoleService; @Resource private SysResourceService sysResourceService; @Resource private GiteeApi giteeApi; @Resource private SysThirdUserService sysThirdUserService; @Override public boolean support(IntegrationAuthentication integrationAuthentication) { return StringUtils.equalsIgnoreCase(integrationAuthentication.getAuthType(), AUTH_TYPE); } @Override public void prepare(IntegrationAuthentication integrationAuthentication) { //将验证码作为用户名密码,覆盖默认的密码验证,使得密码验证通过 //(1、将code值覆盖request中的password参数, 2、code值覆盖UserDetails中的password) String code = integrationAuthentication.getParameter(CODE_PARM_NAME); integrationAuthentication.setPassword(code); } @Override public MyUserDetail authenticate(IntegrationAuthentication integrationAuthentication) throws AuthenticationException { String code = integrationAuthentication.getParameter(CODE_PARM_NAME); if(StringUtils.isBlank(code)){ throw new BadCredentialsException("Gitee授权码为空"); } log.info("用户gitee授权登录:code={}", code); GiteeUserInfo giteeUserInfo; try{ //1、由code获取token GiteeToken giteeToken = giteeApi.getToken(code); String token = giteeToken.getAccess_token(); //2、由token获取gitee用户信息 giteeUserInfo = giteeApi.getUserInfo(token); }catch (Exception e){ log.error("gitee认证异常:"+e.getMessage(), e); throw new AuthenticationServiceException("gitee认证异常"); } Integer loginUserId = sysThirdUserService.getByThirdUserId(String.valueOf(giteeUserInfo.getId()),LoginType.GITEE.name()); if(loginUserId == null){ //用户不存在,注册一个用户 loginUserId = sysThirdUserService.addGiteeUser(giteeUserInfo); } //查询用户 SysUser sysUser = sysUserService.getByPrimaryKey(loginUserId); if(sysUser == null){ //注意,这里不能用UsernameNotFoundException("xxx"),此异常会被BadCredentialsException("用户名或密码错误")覆盖掉 throw new BadCredentialsException("用户手机号码不存在"); } Set roleCodes = sysRoleService.listRoleCodeByUser(sysUser.getId()); Set resourceCodes = sysResourceService.listResourceCodeByUser(sysUser.getId()); return MyUserDetail.build(sysUser, roleCodes, resourceCodes); } @Override public void complete(IntegrationAuthentication integrationAuthentication) { } } ``` 这里做了三个工作 1. prepare()中覆盖密码认证 2. authenticate()中根据授权码+我们应用的client信息换取用户token,再根据token从gitee获取用户信息 3. 根据gitee返回的用户信息,获取本地用户(如果无自动注册一个) 如果后面需要对接微信、QQ的话对应增加WeixinIntegrationAuthenticator、QqIntegrationAuthenticator实现具体的逻辑即可,很方便。 ## 集群session 一个配置类+redis的配置文件。很简单 ```java /** * redis Session配置 * pom引入了spring-boot-starter-data-redis依赖,redisConnectionFactory之类的bean走默认配置就可以 * @author zhaoyd * @date 2020-11-01 19:03 */ @Configuration @EnableRedisHttpSession( maxInactiveIntervalInSeconds=24*3600,//超时时间,24小时 redisNamespace = "uaa-demo:spring:session"//在redis上的命名空间,自定义防止不同应用的key混在一起 ) public class RedisSessionConfig { /** * 定义前端凭据的传输方式 token or cookie * HeaderHttpSessionIdResolver 解析Header内对应token * CookieHttpSessionIdResolver SpringBoot默认方式,存储一个“SESSION”的cookie * @return */ @Bean public HttpSessionIdResolver httpSessionIdResolver(){ //使用token方式,解析header中"X-Auth-Token", //return HeaderHttpSessionIdResolver.xAuthToken(); //使用cookie方式,解析cookie中的“SESSION” return new CookieHttpSessionIdResolver(); } } ```