1.前言

最近项目上需要实现一个统一认证服务器,自然而然的就想到了目前最流行的授权解决方案OAuth2.0;客户端主要通过授权码模式、密码模式、客户端凭证模式、隐式授权模式四种方式获取临时的令牌,最后通过令牌获得访问用户资源的权限。关于OAuth2.0的资料很多,这边就不细讲oauth2.0的内容了。
如果是刚接触OAuth2.0的朋友,在刚开始的时候可能会有一些很难理解或者很难想通的地方,这很正常,但是需要多查看资料、多思考,最后把四种模式的流程走一遍,你就会慢慢的理解了。
参考资料:理解OAuth2.0OAuth 2.0的一种解释

本文主要实现了OAuth2.0的统一认证、鉴权功能,但在最基础的功能上做了些改进:
1.自定义的授权码code实现
2.自定义的授权令牌token实现(JWT)
3.授权码code、token等信息使用redis存储,并使用fastjson序列化
4.扩展ClientDetails,添加trusted属性,对于受信任的client跳过用户授权操作
5.不同的资源访问权限配置
6.统一OAuth接口返回格式(包含异常处理)
7.自定义登录界面实现(包含验证码)
8.仿微信用户授权页面实现

2.正文
2.1 数据库

首先我们来说一下关于OAuth2.0的相关的数据库表,OAuth2.0 相关表结构说明

上面链接中的表结构是spring-security-oauth2提供的默认字段,但是实际项目中我们可能不止包含这些字段,例如oauth_client_details中可能包含接入应用的名称、图片信息等。

spring boot 整合 eureka 集群 springboot整合oauth2.0_spring boot


上图是本文所需要使用的数据库表结构,其中code、token等信息直接会存至redis中。

2.2 pom
<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
        <!-- thymeleaf -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <!--thymeleaf对security5的支持依赖-->
        <dependency>
            <groupId>org.thymeleaf.extras</groupId>
            <artifactId>thymeleaf-extras-springsecurity5</artifactId>
        </dependency>
        <!-- aop -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        <!-- lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <!-- spring security -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!-- jdbc -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>

        <!-- mybatis-plus -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.3.1.tmp</version>
        </dependency>
        <!-- alibaba的druid数据库连接池 -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.1.18</version>
        </dependency>
        <!-- json -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.60</version>
        </dependency>
        <!--mysql-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.47</version>
            <scope>runtime</scope>
        </dependency>
        <!-- Redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>
        <!-- spring session -->
        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>

        <!-- oauth2.0 -->
        <dependency>
            <groupId>org.springframework.security.oauth</groupId>
            <artifactId>spring-security-oauth2</artifactId>
            <version>2.3.4.RELEASE</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-taglibs</artifactId>
            <version>4.2.3.RELEASE</version>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.security</groupId>
                    <artifactId>spring-security-acl</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>org.springframework</groupId>
                    <artifactId>spring-beans</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>org.springframework</groupId>
                    <artifactId>spring-core</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>org.springframework</groupId>
                    <artifactId>spring-expression</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
    </dependencies>
2.3 核心代码

由于代码量比较多,本文只贴出核心代码,更多代码请查看源码。

2.3.1 Spring Security配置类
@Slf4j
@Configuration
@EnableWebSecurity
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {

    @Autowired
    private OauthUserDetailService userDetailService;
    @Autowired
    private ValidateCodeFilter validateCodeFilter;
    @Autowired
    private SecurityLoginFailureHandler securityLoginFailureHandler;

    /**
     * 引入OAuth 2.0必须要暴露的bean
     * @return
     * @throws Exception
     */
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        //Ignore, public
        web.ignoring().antMatchers("/public/**", "/static/**");
    }


    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().ignoringAntMatchers("/oauth/authorize", "/oauth/token", "/oauth/rest_token");

        http.authorizeRequests()
                .antMatchers("/code/**").permitAll()
                .antMatchers("/antd/**", "/vue/**", "/img/**").permitAll()
                .antMatchers("/oauth/rest_token*").permitAll()
                .antMatchers("/doLogin").permitAll()
                .antMatchers("/login*").permitAll()
                .antMatchers(HttpMethod.GET, "/login*").anonymous()
                .anyRequest().authenticated()
            .and()
                .formLogin()
                .loginPage("/login")
                .loginProcessingUrl("/doLogin")
                .defaultSuccessUrl("/index")
                // 登录失败异常处理
                .failureHandler(securityLoginFailureHandler)
                //.failureUrl("/login?error=1")
                .usernameParameter("username")
                .passwordParameter("password")
            .and()
                .logout()
                .logoutUrl("/logout")
                .deleteCookies("JSESSIONID")
                .logoutSuccessUrl("/login")
            .and()
                .exceptionHandling()
            .and()
                //验证码过滤器
                .addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class);

        http.authenticationProvider(authenticationProvider());
    }


    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailService);
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        return daoAuthenticationProvider;
    }


    /**
     * BCrypt  加密
     *
     * @return PasswordEncoder
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
2.3.2 OAuth 2.0授权服务器配置
/**
     * oauth2.0授权服务器配置
     */
    @Configuration
    @EnableAuthorizationServer
    protected class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
        @Autowired
        private TokenStore tokenStore;

        @Autowired
        private OauthClientDetailsService oauthClientDetailsService;

        @Autowired
        private OauthCodeService authorizationCodeServices;

        @Autowired
        private OauthUserDetailService userDetailService;

        @Autowired
        @Qualifier("authenticationManagerBean")
        private AuthenticationManager authenticationManager;
        @Autowired
        private OauthWebResponseExceptionTranslator oauthWebResponseExceptionTranslator;

        @Override
        public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
            clients.withClientDetails(oauthClientDetailsService);
        }

        /**
         * token 存储处理类,使用redis
         * @param connectionFactory
         * @return
         */
        @Bean
        public TokenStore tokenStore(RedisConnectionFactory connectionFactory) {
            final RedisTokenStore redisTokenStore = new RedisTokenStore(connectionFactory);
            // 前缀
            redisTokenStore.setPrefix("TOKEN:");
            // 序列化策略,使用fastjson
            redisTokenStore.setSerializationStrategy(new FastJsonRedisTokenStoreSerializationStrategy());
            return redisTokenStore;
        }

        /**
         * 认证端点配置
         * @param endpoints
         * @throws Exception
         */
        @Override
        public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
            endpoints.tokenStore(tokenStore)
                    .allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST)
                    // 自定义认证异常处理
                    .exceptionTranslator(oauthWebResponseExceptionTranslator)
                    // 自定义的授权码模式的code(授权码)处理,使用redis存储
                    .authorizationCodeServices(authorizationCodeServices)
                    // 用户信息service
                    .userDetailsService(userDetailService)
                    // 用户授权确认处理器
                    .userApprovalHandler(userApprovalHandler())
                    // 注入authenticationManager来支持password模式
                    .authenticationManager(authenticationManager)
                    // 自定义授权确认页面
                    .pathMapping("/oauth/confirm_access", "/approval");
        }

        /**
         * AuthorizationServer的端点(/oauth/**)安全配置(访问规则、过滤器、返回结果处理等)
         * @param oauthServer
         * @throws Exception
         */
        @Override
        public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
            // 允许 /oauth/token的端点表单认证
            oauthServer.allowFormAuthenticationForClients()
                    .tokenKeyAccess("permitAll()")
                    // 允许 /oauth/token_check端点的访问
                    .checkTokenAccess("permitAll()");
        }

        @Bean
        public OAuth2RequestFactory oAuth2RequestFactory() {
            return new DefaultOAuth2RequestFactory(oauthClientDetailsService);
        }

        @Bean
        public UserApprovalHandler userApprovalHandler() {
            OauthUserApprovalHandler userApprovalHandler = new OauthUserApprovalHandler();
            userApprovalHandler.setTokenStore(tokenStore);
            userApprovalHandler.setClientDetailsService(oauthClientDetailsService);
            userApprovalHandler.setRequestFactory(oAuth2RequestFactory());
            return userApprovalHandler;
        }

    }
2.3.3 资源服务器配置

资源服务器配置可以配置多个,主要是根据不同的权限可以访问不同的资源。

/**
     * 资源服务器配置
     */
    @Configuration
    @EnableResourceServer
    protected class ApiResourceServerConfiguration extends ResourceServerConfigurerAdapter {

        @Autowired
        private OauthTokenExtractor oauthTokenExtractor;
        @Autowired
        private OauthExceptionEntryPoint oauthExceptionEntryPoint;
        @Autowired
        private OauthAccessDeniedHandler oauthAccessDeniedHandler;

        @Override
        public void configure(ResourceServerSecurityConfigurer resources) {
            resources.resourceId(RESOURCE_ID).stateless(false);
            // token提取器
            resources.tokenExtractor(oauthTokenExtractor)
                    // token异常处理器
                    .authenticationEntryPoint(oauthExceptionEntryPoint)
                    // 无权限异常处理器
                    .accessDeniedHandler(oauthAccessDeniedHandler);
        }

        @Override
        public void configure(HttpSecurity http) throws Exception {
            http
                // STATELESS表示一定要携带access_token才能访问,无法通过session访问
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
                .requestMatchers().antMatchers("/get/**")
            .and()
                .authorizeRequests()
                .antMatchers("/get/**").access("#oauth2.hasScope('read')");
        }
    }
2.3.4 ClientDetails的service类
@Slf4j
@Service("oauthClientDetailsService")
public class OauthClientDetailsService extends ServiceImpl<OauthClientDetailsMapper, OauthClientDetails> implements ClientDetailsService {

    @Resource
    private RedisTemplate<String, OauthClientDetails> redisTemplate;
    private String prefix = "ClientDetails:";

    @Override
    public ClientDetails loadClientByClientId(String clientId) throws ClientRegistrationException {
        // 优先从redis缓存中获取,不存在则从数据库中获取
        OauthClientDetails oauthClientDetails = redisTemplate.opsForValue().get(prefix + clientId);
        if(oauthClientDetails == null){
            LambdaQueryWrapper<OauthClientDetails> query = new LambdaQueryWrapper<>();
            query.eq(OauthClientDetails::getAppKey, clientId);
            oauthClientDetails = super.getOne(query);
            if(oauthClientDetails == null){
                return null;
            }

            redisTemplate.opsForValue().set(prefix + clientId, oauthClientDetails, 1, TimeUnit.HOURS);
        }
        return new ClientDetailsAdapter(oauthClientDetails);
    }

由于数据库表返回的类是OAuthClientDetails,因此需要一个适配器类用来兼容该实体类

public class ClientDetailsAdapter implements ClientDetails {

    private OauthClientDetails clientDetails;

    public ClientDetailsAdapter(OauthClientDetails clientDetails) {
        this.clientDetails = clientDetails;
    }

    @Override
    public String getClientId() {
        return clientDetails.getAppKey();
    }

    @Override
    public Set<String> getResourceIds() {
        return CollectionUtil.getSetBySplit(clientDetails.getResourceIds());
    }

    @Override
    public boolean isSecretRequired() {
        return true;
    }

    @Override
    public String getClientSecret() {
        return clientDetails.getAppSecret();
    }

    /**
     * 客户端是否为特定范围,如果该值返回false,则忽略身份认证的请求范围(scope的值)
     * @return
     */
    @Override
    public boolean isScoped() {
        return true;
    }

    /**
     * 客户端拥有的授权范围
     * @return
     */
    @Override
    public Set<String> getScope() {
        return CollectionUtil.getSetBySplit(clientDetails.getScope());
    }

    /**
     * 客户端拥有的授权方式
     * @return
     */
    @Override
    public Set<String> getAuthorizedGrantTypes() {
        return CollectionUtil.getSetBySplit(clientDetails.getAuthorizedGrantTypes());
    }

    @Override
    public Set<String> getRegisteredRedirectUri() {
        return CollectionUtil.getSetBySplit(clientDetails.getRedirectUri());
    }

    @Override
    public Collection<GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> list = new ArrayList<>();
        for (String item : CollectionUtil.getSetBySplit(clientDetails.getAuthorities())) {
            GrantedAuthority authority = new SimpleGrantedAuthority(item);
            list.add(authority);
        }
        return list;
    }

    @Override
    public Integer getAccessTokenValiditySeconds() {
        return clientDetails.getAccessTokenValidity();
    }

    @Override
    public Integer getRefreshTokenValiditySeconds() {
        return clientDetails.getRefreshTokenValidity();
    }

    @Override
    public boolean isAutoApprove(String scope) {
        return false;
    }

    @Override
    public Map<String, Object> getAdditionalInformation() {
        return null;
    }

    /**
     * 第三应用是否可信任
     * @return
     */
    public boolean isTrusted(){
        return clientDetails.getTrusted().intValue() == 1;
    }

    public OauthClientDetails getClientDetails() {
        return clientDetails;
    }
2.3.5 UserDetails的service类
@Service
public class OauthUserDetailService extends ServiceImpl<OauthUserMapper, OauthUser> implements UserDetailsService, Serializable {

    private static final long serialVersionUID = 1170885289644276974L;
    @Resource
    private OauthUserMapper mapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        OauthUser user = mapper.getUserByAccount(username);
        if(user == null){
            throw new UsernameNotFoundException("该用户不存在");
        }

        return new UserDetailsAdapter(user);
    }

    /**
     * 获取用户受保护的信息
     * @param account
     * @return
     */
    public JSONObject getProtectedUserInfo(String account){
        OauthUser user = mapper.getUserByAccount(account);
        if(user != null){
            JSONObject result = new JSONObject();
            result.put("id", user.getId());
            result.put("userName", user.getUserName());
            result.put("phone", user.getPhone());
            result.put("gender", user.getGender());
            if(!CollectionUtils.isEmpty(user.getRoleList())){
                result.put("role", user.getRoleList().stream().map(OauthRole::getRoleCode).collect(Collectors.toList()));
            }

            return result;
        }
        return null;
    }

    /**
     * 修改用户姓名
     * @param account
     * @param userName
     * @return
     */
    public boolean updateUserName(String account, String userName){
        OauthUser user = mapper.getUserByAccount(account);
        if(user != null){
            user.setUserName(userName);
            user.setUpdateTime(LocalDateTime.now());
            mapper.updateById(user);

            return true;
        }
        return false;
    }
}

同样,这边也用到了适配器模式

@Setter
public class UserDetailsAdapter implements UserDetails {

    // fastJson反序列化的时候需要有属性去接受redis中的属性值
    private String username;
    private String password;
    /**
     * 权限
     */
    private List<GrantedAuthority> authorities;
    private boolean enable;

    /**
     * 角色的默认前缀
     * @see {@link org.springframework.security.access.expression.SecurityExpressionRoot#setDefaultRolePrefix}
     */
    private static final String defaultRolePrefix = "ROLE_";

    // 由于使用了FastJson进行序列化和反序列化,因此必须要有一个空的构造器
    public UserDetailsAdapter(){

    }

    public UserDetailsAdapter(OauthUser oauthUser) {
        this.username = oauthUser.getAccount();
        this.password = oauthUser.getPassword();

        List<GrantedAuthority> list = new ArrayList<>();
        if(!StringUtils.isEmpty(oauthUser.getRoleList())){
            for (OauthRole role : oauthUser.getRoleList()) {
                GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(defaultRolePrefix + role.getRoleCode());
                list.add(grantedAuthority);
            }
        }
        this.authorities = list;
        this.enable = (oauthUser.getDelFlag() == 0 && oauthUser.getStatus() == 0);
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return enable;
    }
2.3.6 统一返回数据模型

定义一个Result类,用于接口返回

@Data
public class Result implements Serializable {

	private static final long serialVersionUID = 1L;

	/**
	 * 成功标志
	 */
	private boolean success = true;

	/**
	 * 返回处理消息
	 */
	private String msg = "ok";

	/**
	 * 返回代码
	 */
	private Integer code = 0;

	/**
	 * 返回数据对象 data
	 */
	private Object data;


	public Result() {

	}
    public Result(int code, String msg, Object data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }

    public Result(ResultStatusCode resultStatusCode, Object data) {
        this(resultStatusCode.getCode(), resultStatusCode.getMsg(), data);
    }

    public Result(int code, String msg) {
        this(code, msg, null);
    }

    public Result(ResultStatusCode resultStatusCode) {
        this(resultStatusCode, null);
    }

    public boolean isSuccess() {
        return this.code == 0;
    }

    public static Result ok() {
		return new Result(ResultStatusCode.OK);
	}

	public static Result ok(Object obj) {
        return new Result(ResultStatusCode.OK, obj);
	}

	public static Result error(ResultStatusCode resultStatusCode){
	    return new Result(resultStatusCode);
    }

    public static Result error(ResultStatusCode resultStatusCode, Object obj){
        return new Result(resultStatusCode, obj);
    }

    public static Result error(String msg){
	    return new Result(5000, msg, null);
    }
}

错误码定义类

public enum ResultStatusCode {
    OK(0, "OK"),
    BAD_REQUEST(400, "参数解析失败"),
    INVALID_TOKEN(401, "无效的Access-Token"),
    METHOD_NOT_ALLOWED(405, "不支持当前请求方法"),
    SYSTEM_ERR(500, "服务器运行异常"),
    PERMISSION_DENIED(10001, "权限不足"),
    TOKEN_MISS(10002, "Token缺失");

    private int code;

    private String msg;

    public int getCode() {
        return code;
    }

    public void setCode(int code) {
        this.code = code;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    private ResultStatusCode(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }
}
2.3.7 OAuth2.0 接口返回结果处理

在AuthorizationServerConfiguration和ApiResourceServerConfiguration类中,我们配置许多自定义异常处理类,主要是用来规范接口输出,客户端接入时可以直接通过返回的错误码来定义问题。
无权限异常处理

@Component
public class OauthAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) {
        accessDeniedException.printStackTrace();
        response.setStatus(HttpStatus.OK.value());
        ResultUtil.writeJavaScript(response, Result.error(ResultStatusCode.PERMISSION_DENIED));
    }
}

token异常处理

@Component
public class OauthExceptionEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException){
        authException.printStackTrace();
        Throwable cause = authException.getCause();

        response.setStatus(HttpStatus.OK.value());
        if(cause instanceof InvalidTokenException) {
            ResultUtil.writeJavaScript(response, Result.error(ResultStatusCode.INVALID_TOKEN));

        }else{
            ResultUtil.writeJavaScript(response, Result.error(ResultStatusCode.TOKEN_MISS));
        }
    }
}

成功返回结果处理

@ControllerAdvice(basePackages = "org.springframework.security.oauth2.provider.endpoint")
public class ResponseAdvice implements ResponseBodyAdvice {
    @Override
    public boolean supports(MethodParameter returnType, Class converterType) {
        return true;
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        // 只对oauth提供的相关接口返回的Json数据进行处理
        if(request.getURI().getPath().startsWith("/oauth") && selectedContentType.includes(MediaType.APPLICATION_JSON)){
            // 排除异常处理中已经处理过的json结果
            if(!JSON.toJSONString(body).contains("code")){
                return Result.ok(body);
            }
        }
        return body;
    }
}
2.4 自定义登录页面(thymeleaf、vue、ant design vue)

由于Spring-Security默认的登录页面比较单调,并且没有实现验证码功能,所以需要自己去实现登录页面,在WebSecurityConfigurer配置类中已经指定了登录界面url为/login,对应html如下

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>统一认证登录页面</title>

    <link th:href="@{/antd/dist/antd.css}" rel="stylesheet">

    <script th:src="@{/vue/vue.min.js}"></script>
    <script th:src="@{/antd/dist/antd.min.js}"></script>
    <script th:src="@{/vue/moment.min.js}"></script>
    <script th:src="@{/vue/jquery.min.js}"></script>

    <style type="text/css">
        .main {
            background-color: #2b4b6b;
            height: 100%;
            width: 100%;
        }
        .login_box {
            width: 450px;
            background-color: #fff;
            border-radius: 3px;
            position: absolute;
            left: 50%;
            top: 50%;
            transform: translate(-50%, -50%);
        }
        .login_box .avator_box {
            height: 130px;
            width: 130px;
            border: 1px solid #eee;
            border-radius: 50%;
            padding: 10px;
            box-shadow: 0 0 10px #ddd;
            position: absolute;
            left: 50%;
            transform: translate(-50%, -50%);
            background-color: #fff;
        }
        .login_box .avator_box img {
            width: 100%;
            height: 100%;
            border-radius: 50%;
            background-color: #eee;
        }

        .login_box .login_form {
            width: 100%;
            margin-top: 20%;
            padding: 0 10%;
        }
    </style>
</head>
<body>
    <div id="app" style="width: 100vw; height: 100vh;">
        <div class="main">
            <div class="login_box">
                <div class="avator_box">
                    <img th:src="@{/img/logo.png}" />
                </div>
                <div class="login_form">
                    <form id="userForm" th:action="@{/doLogin}" method="post" style="display: none;">
                        <input name="username" :value="form.username">
                        <input name="password" type="password" :value="form.password">
                        <input name="code" :value="form.code">
                        <input name="timestamp" :value="currdatetime">
                    </form>

                    <a-form-model ref="loginForm" :model="form" :rules="rules" @keyup.enter.native="handleSubmit">
                        <a-form-model-item ref="username" prop="username">
                            <a-input v-model="form.username" size="large" placeholder="请输入登录账号">
                                <a-icon
                                        slot="prefix"
                                        type="user"
                                        :style="{ color: 'rgba(0,0,0,.25)' }"
                                />
                            </a-input>
                        </a-form-model-item>

                        <a-form-model-item ref="password" prop="password">
                            <a-input v-model="form.password" type="password" size="large" placeholder="请输入密码">
                                <a-icon
                                        slot="prefix"
                                        type="lock"
                                        :style="{ color: 'rgba(0,0,0,.25)' }"
                                />
                            </a-input>
                        </a-form-model-item>

                        <a-form-model-item ref="code" prop="code">
                            <a-row>
                                <a-col :span="16">
                                    <a-input v-model="form.code" size="large" placeholder="请输入验证码"></a-input>
                                </a-col>
                                <a-col :span="8" style="text-align: right;">
                                    <img v-if="requestCodeSuccess" :src="randCodeImage" @click="handleChangeCheckCode"/>
                                    <img v-else th:src="@{/img/checkcode.png}" @click="handleChangeCheckCode"/>
                                </a-col>
                            </a-row>

                        </a-form-model-item>

                        <a-form-model-item>
                            <a-button type="primary" @click="handleSubmit" size="large" style="width: 100%;">
                                登录
                            </a-button>
                        </a-form-model-item>
                    </a-form-model>
                </div>
            </div>
        </div>
    </div>


    <script type="text/javascript">
        var vue = new Vue({
            el: '#app',
            data: {
                form: {
                    username: '',
                    password: '',
                    code: ''
                },
                rules: {
                    username: [{required: true, message: '请输入登录账号'}],
                    password: [{required: true, message: '请输入密码'}],
                    code: [{required: true, message: '请输入验证码'}]
                },
                currdatetime: '',
                requestCodeSuccess: false,
                randCodeImage: ''
            },
            created: function(){
                this.handleChangeCheckCode()

                var error = this.getUrlParam('error');
                if(error){
                    if(error == 'codeError'){
                        this.$notification.error({
                            message: '登录失败',
                            description: '验证码错误'
                        })
                    }else{
                        this.$notification.error({
                            message: '登录失败',
                            description: '账号或者密码错误'
                        })
                    }
                }
            },
            methods: {
                handleChangeCheckCode: function() {
                    this.currdatetime = new Date().getTime();
                    var that = this
                    $.get('/code/' + this.currdatetime, function(res){
                        if(res.success){
                            that.randCodeImage = res.data
                            that.requestCodeSuccess=true
                        }else{
                            that.$message.error(res.message)
                            that.requestCodeSuccess=false
                        }
                    },'json')
                },
                handleSubmit: function(){
                    this.$refs.loginForm.validate(function(valid){
                        if(valid){
                            $('#userForm').submit()
                        }
                    })
                },
                getUrlParam: function (paraName) {
                    var url = document.location.toString();
                    var arrObj = url.split("?");

                    if (arrObj.length > 1) {
                        var arrPara = arrObj[1].split("&");
                        var arr;

                        for (var i = 0; i < arrPara.length; i++) {
                            arr = arrPara[i].split("=");

                            if (arr != null && arr[0] == paraName) {
                                return arr[1];
                            }
                        }
                        return "";
                    } else {
                        return "";
                    }
                }
            }
        })
    </script>
</body>
</html>

其中验证码需要通过过滤器处理,在WebSecurityConfigurer配置类中添加自定义过滤器

@Component
public class ValidateCodeFilter extends OncePerRequestFilter {
    @Autowired
    private SecurityLoginFailureHandler securityLoginFailureHandler;
    @Resource
    private RedisTemplate<String, String> redisTemplate;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if(request.getRequestURI().equals("/doLogin") && request.getMethod().equalsIgnoreCase(HttpMethod.POST.name())){
            try {
                validate(request);
            }catch (ValidateCodeException e){
                securityLoginFailureHandler.onAuthenticationFailure(request, response, e);
                return;
            }
        }
        // 通过的情况下,继续执行其他过滤器链
        filterChain.doFilter(request, response);
    }

    private void validate(HttpServletRequest request) throws ServletRequestBindingException {
        String code = ServletRequestUtils.getStringParameter(request, "code");
        String timestamp = ServletRequestUtils.getStringParameter(request, "timestamp");

        String realKey = MD5Util.MD5Encode(code.toLowerCase() + timestamp, "utf-8");

        String serverCode = redisTemplate.opsForValue().get(realKey);
        redisTemplate.delete(realKey);

        if(serverCode == null || !serverCode.equalsIgnoreCase(code)){
            throw new ValidateCodeException("验证码不正确");
        }
    }
}
3 测试

在测试之前我们需要有用户信息和三方应用信息数据,博主为了偷懒没有写单独的维护页面,直接在测试类(SpringBootSecurityOauthApplicationTests)中单独写了createUserAndRole()和createClientDetails()方法,修改其中的参数直接运行即可;不过需要注意的是OauthUser的密码和OauthClientDetails的应用密钥都是采用的BCrypt加密,因此需要提前保存

登录页面效果如下:

spring boot 整合 eureka 集群 springboot整合oauth2.0_OAuth2.0_02


spring boot 整合 eureka 集群 springboot整合oauth2.0_spring boot_03


spring boot 整合 eureka 集群 springboot整合oauth2.0_后端_04


OAuth2.0授权码方式测试:

spring boot 整合 eureka 集群 springboot整合oauth2.0_后端_05


spring boot 整合 eureka 集群 springboot整合oauth2.0_spring boot_06


spring boot 整合 eureka 集群 springboot整合oauth2.0_后端_07


spring boot 整合 eureka 集群 springboot整合oauth2.0_spring boot_08


用户资源访问测试如下:

spring boot 整合 eureka 集群 springboot整合oauth2.0_spring boot_09


spring boot 整合 eureka 集群 springboot整合oauth2.0_统一认证_10


异常情况测试:

spring boot 整合 eureka 集群 springboot整合oauth2.0_java_11


spring boot 整合 eureka 集群 springboot整合oauth2.0_后端_12


spring boot 整合 eureka 集群 springboot整合oauth2.0_OAuth2.0_13

4.结尾

本文只包含OAuth2.0认证服务器端和资源服务器端,不包含OAuth2.0的客户端,客户端需要自己去实现。

完整源码地址

参考
1.https://www.oauth.com/ 2.https://projects.spring.io/spring-security-oauth/docs/oauth2.html