在前面的篇章中,我们在请求认证服务器的交互过程去中,如果出现了异常,则服务器响应给客户端的信息格式非常不友好,我们希望服务器在发生异常时,将异常信息按照固定的 json 格式返回给客户端,这时需要我们来自定义异常,我们需要将异常进行捕获,然后按照我们定义的格式输出。那么本篇,我们就来介绍一下如何自定义异常。

1. 认证服务器异常举例

下面先列举一些异常出现的状况。

1.1. 用户名不存在

我们在使用 postman 向认证服务器获取 token 信息的时候,如果输入的用户名不存在,则直接返回下面的异常。

Spring Authorization Server (十)自定义异常_Spring Security

上面的响应结果中,服务器给 postman 返回了登录页 html,这让客户端有点摸不着头脑,其实控制台是有准确输出异常信息的。

Spring Authorization Server (十)自定义异常_Spring Boot3_02

1.2. 用户密码不正确

我们在使用 postman 向认证服务器获取 token 信息的时候,如果输入的密码不正确,则直接返回下面的异常。

Spring Authorization Server (十)自定义异常_Authorization Server_03

该异常信息比起上面的用户名不存在所响应的异常,已经是比较友好的了,起码已经准确的反馈了异常信息,并且也是输出 json 格式,但是这还不能达到我们的期望,我们按照下面的格式进行输出。

{
    "code": 201,
    "message": "失败",
    "data": "密码不正确!"
}


1.3. 请求受保护的资源未带令牌

其实认证服务器也可以是资源资源服务器,将之前资源服务器项目中的 MessagesController 复制一份到认证服务器 org.oauth.server.controler 目录下,代码如下。

@RestController
public class MessagesController {

    @GetMapping("/messages1")
    public String getMessages1() {
        return " hello Message 1";
    }

    @GetMapping("/messages2")
    @PreAuthorize("hasAuthority('SCOPE_profile')")
    public String getMessages2() {
        return " hello Message 2";
    }

    @GetMapping("/messages3")
    @PreAuthorize("hasAuthority('SCOPE_Message')")
    public String getMessages3() {
        return " hello Message 3";
    }

    @GetMapping("/messages4")
    @PreAuthorize("hasAuthority('SCOPE_USER')")
    public String getMessages4() {
        return " hello Message 4";
    }

}

DefaultSecurityConfig 配置类添加 @EnableMethodSecurity(jsr250Enabled = true, securedEnabled = true) 注解打开权限校验。

我们在使用 postman 请求受保护的资源接口的时候,如果请求未带上令牌(token),则直接返回下面的异常。

Spring Authorization Server (十)自定义异常_自定义异常_04


Spring Authorization Server (十)自定义异常_Authorization Server_05

上面的异常直接返回 401 状态,Body 中什么信息都没有,在响应头中就直接返回“Bearer”。

1.4. 请求受保护的资源令牌权限不足

postman 获取 token 如下。

Spring Authorization Server (十)自定义异常_自定义异常_06

将 access_token 使用 jwt 解析如下。

Spring Authorization Server (十)自定义异常_自定义异常_07

通过 jwt 解析可以看到,access_token 拥有的权限为 address email phone USER,而访问 “http://spring-oauth-server:9000/messages3”接口需要 Message 权限,将 access_token 拿去访问 http://spring-oauth-server:9000/messages3 接口,结果如下。

Spring Authorization Server (十)自定义异常_Spring Boot3_08

Spring Authorization Server (十)自定义异常_Spring Boot3_09

上面的异常响应信息中,状态直接返回 403,Body 中也是什么信息都没有,响应头中返回的“Bearer error="insufficient_scope", error_descriptinotallow="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"” 是准确返回了异常信息,但我们也是希望通过 json 格式响应到 Body 中。

2. 框架捕获异常代码逻辑

在 Spring Authorization Server 的过滤链中有一个叫 ExceptionTranslationFilter 的过滤器,在认证或授权过程中,如果出现 AuthenticationException(认证异常)和 AccessDeniedException(授权异常),都由 ExceptionTranslationFilter 过滤器捕获进行处理。ExceptionTranslationFilter 捕获异常的代码如下。

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
    try {
        chain.doFilter(request, response);
    } catch (IOException var7) {
        throw var7;
    } catch (Exception var8) {
        Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(var8);
        RuntimeException securityException = (AuthenticationException)this.throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class, causeChain);
        if (securityException == null) {
            securityException = (AccessDeniedException)this.throwableAnalyzer.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
        }

        if (securityException == null) {
            this.rethrow(var8);
        }

        if (response.isCommitted()) {
            throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", var8);
        }

        this.handleSpringSecurityException(request, response, chain, (RuntimeException)securityException);
    }

}

当 ExceptionTranslationFilter 捕获到 AuthenticationException 或 AccessDeniedException 后,交由 this.handleSpringSecurityException(request, response, chain, (RuntimeException)securityException) 方法进行处理。this.handleSpringSecurityException(request, response, chain, (RuntimeException)securityException) 方法代码如下。

private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, RuntimeException exception) throws IOException, ServletException {
    if (exception instanceof AuthenticationException) {
        this.handleAuthenticationException(request, response, chain, (AuthenticationException)exception);
    } else if (exception instanceof AccessDeniedException) {
        this.handleAccessDeniedException(request, response, chain, (AccessDeniedException)exception);
    }
}

该方法对异常进行分类处理,如果是 AuthenticationException 类型异常,则交由 this.handleAuthenticationException(request, response, chain, (AuthenticationException)exception)方法进行处理,如果是 AccessDeniedException 类型异常,则交由this.handleAccessDeniedException(request, response, chain, (AccessDeniedException)exception) 方法进行处理。

下面对 AuthenticationException、AccessDeniedException 异常处理分别进行讲解。

2.1. AuthenticationException异常

AuthenticationException 异常进到 this.handleAuthenticationException(request, response, chain, (AuthenticationException)exception) 方法,代码如下。

private void handleAuthenticationException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException exception) throws ServletException, IOException {
        this.logger.trace("Sending to authentication entry point since authentication failed", exception);
        this.sendStartAuthentication(request, response, chain, exception);
    }

上面方法中将 AuthenticationException 异常交由 this.sendStartAuthentication(request, response, chain, exception) 方法进行处理,this.sendStartAuthentication(request, response, chain, exception) 方法代码如下。

protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException {
    SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
    this.securityContextHolderStrategy.setContext(context);
    this.requestCache.saveRequest(request, response);
    this.authenticationEntryPoint.commence(request, response, reason);
}

进到 this.authenticationEntryPoint.commence(request, response, reason) 方法查看代码,发现是个接口。

public interface AuthenticationEntryPoint {
    void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException;
}

也就是说,AuthenticationException 异常信息由 AuthenticationEntryPoint 的实现类进行输出。

框架默认由 DelegatingAuthenticationEntryPoint 类进行处理, DelegatingAuthenticationEntryPoint 的处理方法代码如下。

public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
    Iterator var4 = this.entryPoints.keySet().iterator();

    RequestMatcher requestMatcher;
    do {
        if (!var4.hasNext()) {
            logger.debug(LogMessage.format("No match found. Using default entry point %s", this.defaultEntryPoint));
            this.defaultEntryPoint.commence(request, response, authException);
            return;
        }

        requestMatcher = (RequestMatcher)var4.next();
        logger.debug(LogMessage.format("Trying to match using %s", requestMatcher));
    } while(!requestMatcher.matches(request));

    AuthenticationEntryPoint entryPoint = (AuthenticationEntryPoint)this.entryPoints.get(requestMatcher);
    logger.debug(LogMessage.format("Match found! Executing %s", entryPoint));
    entryPoint.commence(request, response, authException);
}

上面代码中,最后是从 this.entryPoints 链表中取出一个匹配的 AuthenticationEntryPoint 实例,然后交由它处理。我们可以打个断点,输入错误的密码进行调试一下。

Spring Authorization Server (十)自定义异常_Spring Boot3_10

从上面的断点中可以看到,从 this.entryPoints 链表中链表中取出的是LoginUrlAuthenticationEntryPoint,LoginUrlAuthenticationEntryPoint 处理异常的代码如下。

public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
    String redirectUrl;
    if (!this.useForward) {
        redirectUrl = this.buildRedirectUrlToLoginPage(request, response, authException);
        this.redirectStrategy.sendRedirect(request, response, redirectUrl);
    } else {
        redirectUrl = null;
        if (this.forceHttps && "http".equals(request.getScheme())) {
            redirectUrl = this.buildHttpsRedirectUrlForRequest(request);
        }

        if (redirectUrl != null) {
            this.redirectStrategy.sendRedirect(request, response, redirectUrl);
        } else {
            String loginForm = this.determineUrlToUseForThisRequest(request, response, authException);
            logger.debug(LogMessage.format("Server side forward to: %s", loginForm));
            RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);
            dispatcher.forward(request, response);
        }
    }
}

从上面的代码中可以看到,最后是由 dispatcher.forward(request, response) 方法将请求转发到“login”页面。

为什么是 LoginUrlAuthenticationEntryPoint 呢?

我们打开 AuthorizationServerConfig 配置类,看看我们的异常配置,我们配置的正是 LoginUrlAuthenticationEntryPoint。

Spring Authorization Server (十)自定义异常_Authorization Server_11


2.2. AccessDeniedException异常

AccessDeniedException 异常进到 this.handleAccessDeniedException(request, response, chain, (AccessDeniedException)exception) 方法,代码如下。

private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
        Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
        boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
        if (!isAnonymous && !this.authenticationTrustResolver.isRememberMe(authentication)) {
            if (this.logger.isTraceEnabled()) {
                this.logger.trace(LogMessage.format("Sending %s to access denied handler since access is denied", authentication), exception);
            }

            this.accessDeniedHandler.handle(request, response, exception);
        } else {
            if (this.logger.isTraceEnabled()) {
                this.logger.trace(LogMessage.format("Sending %s to authentication entry point since access is denied", authentication), exception);
            }

            this.sendStartAuthentication(request, response, chain, new InsufficientAuthenticationException(this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication", "Full authentication is required to access this resource")));
        }

    }

上面方法的代码中,先是判断请求是否有带上令牌(token),如果没有,则交由 sendStartAuthentication 方法进行处理,方法代码如下。

protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException {
    SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
    this.securityContextHolderStrategy.setContext(context);
    this.requestCache.saveRequest(request, response);
    this.authenticationEntryPoint.commence(request, response, reason);
}

this.authenticationEntryPoint.commence(request, response, reason) 就是上面讲到的AuthenticationEntryPoint 接口。

public interface AuthenticationEntryPoint {
    void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException;
}

但这里框架交由 BearerTokenAuthenticationEntryPoint 类进行处理,主要是响应头信息返回 401 状态,告诉客户端需要带上有效的令牌(token)进行访问(Full authentication is required to access this resource),方法代码如下。

public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) {
    HttpStatus status = HttpStatus.UNAUTHORIZED;
    Map<String, String> parameters = new LinkedHashMap();
    if (this.realmName != null) {
        parameters.put("realm", this.realmName);
    }

    if (authException instanceof OAuth2AuthenticationException) {
        OAuth2Error error = ((OAuth2AuthenticationException)authException).getError();
        parameters.put("error", error.getErrorCode());
        if (StringUtils.hasText(error.getDescription())) {
            parameters.put("error_description", error.getDescription());
        }

        if (StringUtils.hasText(error.getUri())) {
            parameters.put("error_uri", error.getUri());
        }

        if (error instanceof BearerTokenError) {
            BearerTokenError bearerTokenError = (BearerTokenError)error;
            if (StringUtils.hasText(bearerTokenError.getScope())) {
                parameters.put("scope", bearerTokenError.getScope());
            }

            status = ((BearerTokenError)error).getHttpStatus();
        }
    }

    String wwwAuthenticate = computeWWWAuthenticateHeaderValue(parameters);
    response.addHeader("WWW-Authenticate", wwwAuthenticate);
    response.setStatus(status.value());
}

如果客户端请求有带上令牌(token),但令牌(token)权限不足,则交由 DelegatingAccessDeniedHandler 类进行处理,代码如下。

public final class DelegatingAccessDeniedHandler implements AccessDeniedHandler {
    private final LinkedHashMap<Class<? extends AccessDeniedException>, AccessDeniedHandler> handlers;
    private final AccessDeniedHandler defaultHandler;

    public DelegatingAccessDeniedHandler(LinkedHashMap<Class<? extends AccessDeniedException>, AccessDeniedHandler> handlers, AccessDeniedHandler defaultHandler) {
        Assert.notEmpty(handlers, "handlers cannot be null or empty");
        Assert.notNull(defaultHandler, "defaultHandler cannot be null");
        this.handlers = handlers;
        this.defaultHandler = defaultHandler;
    }

    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        Iterator var4 = this.handlers.entrySet().iterator();

        Map.Entry entry;
        Class handlerClass;
        do {
            if (!var4.hasNext()) {
                this.defaultHandler.handle(request, response, accessDeniedException);
                return;
            }

            entry = (Map.Entry)var4.next();
            handlerClass = (Class)entry.getKey();
        } while(!handlerClass.isAssignableFrom(accessDeniedException.getClass()));

        AccessDeniedHandler handler = (AccessDeniedHandler)entry.getValue();
        handler.handle(request, response, accessDeniedException);
    }
}

上面代码中,handler.handle(request, response, accessDeniedException) 是AccessDeniedHandler 接口,代码如下。

public interface AccessDeniedHandler {
    void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException;
}

这里框架交由 BearerTokenAccessDeniedHandler 类进行处理,主要是响应头信息返回 403 状态,告诉客户端权限不足(Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token."),代码如下。

public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) {
    Map<String, String> parameters = new LinkedHashMap();
    if (this.realmName != null) {
        parameters.put("realm", this.realmName);
    }

    if (request.getUserPrincipal() instanceof AbstractOAuth2TokenAuthenticationToken) {
        parameters.put("error", "insufficient_scope");
        parameters.put("error_description", "The request requires higher privileges than provided by the access token.");
        parameters.put("error_uri", "https://tools.ietf.org/html/rfc6750#section-3.1");
    }

    String wwwAuthenticate = computeWWWAuthenticateHeaderValue(parameters);
    response.addHeader("WWW-Authenticate", wwwAuthenticate);
    response.setStatus(HttpStatus.FORBIDDEN.value());
}

2.3. 框架异常处理总结

通过上面关于 AuthenticationException、AccessDeniedException 异常处理的讲解,如果我们要自定义对AuthenticationException、AccessDeniedException 异常的处理,那么我们就需自定义AuthenticationEntryPoint、AccessDeniedException 的实现类,然后将自定义的异常实现类设置到配置中去。我们可以通过 accessDeniedHandler(AccessDeniedHandler accessDeniedHandler)、authenticationEntryPoint(AuthenticationEntryPoint authenticationEntryPoint) 方法,将自定义的异常设置到配置中去。

需要注意的是,只是处理 AuthenticationException、AccessDeniedException 异常还不够,其他的异常还得额外进行处理,例如:ArithmeticException、RuntimeException、IoException。

3. 实现自定义异常

下面我们来开始添加自定义异常的代码。

3.1. 添加响应结果工具类

为了让捕获到的异常按照固定的 json 格式输出,我们需要添加状态枚举类和结果响应类。

状态枚举类代码如下。

@Getter
public enum ResultCodeEnum {

    SUCCESS(200,"成功"),
    FAIL(201, "失败");

    private Integer code;
    private String message;

    private ResultCodeEnum(Integer code, String message) {
        this.code = code;
        this.message = message;
    }
}

结果响应类代码如下。

@Data
public class ResponseResult<T> {

    /**
     * 状态码
     */
    private Integer code;
    /**
     * 返回信息
     */
    private String message;
    /**
     * 数据
     */
    private T data;

    private ResponseResult() {}


    /**
     *
     * @param body
     * @param resultCodeEnum
     * @return
     * @param <T>
     * @author  Rommel
     * @date    2023/7/31-10:46
     * @version 1.0
     * @description  构造返回结果
     */
    public static <T> ResponseResult<T> build(T body, ResultCodeEnum resultCodeEnum) {
        ResponseResult<T> result = new ResponseResult<>();
        //封装数据
        if(body != null) {
            result.setData(body);
        }
        //状态码
        result.setCode(resultCodeEnum.getCode());
        //返回信息
        result.setMessage(resultCodeEnum.getMessage());
        return result;
    }


    /**
     *
     * @return
     * @param <T>
     * @author  Rommel
     * @date    2023/7/31-10:45
     * @version 1.0
     * @description  成功-无参
     */
    public static<T> ResponseResult<T> ok() {
        return build(null,ResultCodeEnum.SUCCESS);
    }


    /**
     *
     * @param data
     * @return
     * @param <T>
     * @author  Rommel
     * @date    2023/7/31-10:45
     * @version 1.0
     * @description  成功-有参
     */
    public static<T> ResponseResult<T> ok(T data) {
        return build(data,ResultCodeEnum.SUCCESS);
    }

    /**
     *
     * @return
     * @param <T>
     * @author  Rommel
     * @date    2023/7/31-10:45
     * @version 1.0
     * @description  失败-无参
     */
    public static<T> ResponseResult<T> fail() {
        return build(null,ResultCodeEnum.FAIL);
    }

    /**
     *
     * @param data
     * @return
     * @param <T>
     * @author  Rommel
     * @date    2023/7/31-10:45
     * @version 1.0
     * @description  失败-有参
     */
    public static<T> ResponseResult<T> fail(T data) {
        return build(data,ResultCodeEnum.FAIL);
    }

    public ResponseResult<T> message(String msg){
        this.setMessage(msg);
        return this;
    }

    public ResponseResult<T> code(Integer code){
        this.setCode(code);
        return this;
    }

    /**
     *
     * @param response
     * @param e
     * @throws IOException
     * @author  Rommel
     * @date    2023/7/31-10:45
     * @version 1.0
     * @description  异常响应
     */
    public static void exceptionResponse(HttpServletResponse response, Exception e) throws AccessDeniedException, AuthenticationException,IOException {

        String message = null;
        if(e instanceof OAuth2AuthenticationException o){
            message = o.getError().getDescription();
        }else{
            message = e.getMessage();
        }
        exceptionResponse(response,message);
    }

    /**
     *
     * @param response
     * @param message
     * @throws AccessDeniedException
     * @throws AuthenticationException
     * @throws IOException
     * @author  Rommel
     * @date    2023/8/1-16:18
     * @version 1.0
     * @description  异常响应
     */
    public static void exceptionResponse(HttpServletResponse response,String message) throws AccessDeniedException, AuthenticationException,IOException {

        ResponseResult responseResult = ResponseResult.fail(message);
        Gson gson = new Gson();
        String jsonResult = gson.toJson(responseResult);
        response.setStatus(HttpServletResponse.SC_OK);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(Charsets.UTF_8.name());
        response.getWriter().print(jsonResult);

    }


3.2. 自定义异常类

当我们需要对一些判断逻辑进行异常抛出时,为了让抛出的异常能被捕获,我们新建一个自己的异常类,继承 AuthenticationException,代码如下。

public class MyAuthenticationException extends AuthenticationException {
    public MyAuthenticationException(String msg) {
        super(msg);
    }
}

将自己的判断逻辑异常抛出都改成抛出 MyAuthenticationException。

3.3. 自定义AuthenticationEntryPoint

新建 AuthenticationEntryPoint 的实现类,取名 MyAuthenticationEntryPoint,代码如下。

public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {

        if(authException instanceof InsufficientAuthenticationException){
            String accept = request.getHeader("accept");
            if(accept.contains(MediaType.TEXT_HTML_VALUE)){
                //如果是html请求类型,则返回登录页
                LoginUrlAuthenticationEntryPoint loginUrlAuthenticationEntryPoint = new LoginUrlAuthenticationEntryPoint(OAuth2Constant.LOGIN_URL);
                loginUrlAuthenticationEntryPoint.commence(request,response,authException);
            }else {
                //如果是api请求类型,则返回json
                ResponseResult.exceptionResponse(response,"需要带上令牌进行访问");
            }
        }else if(authException instanceof InvalidBearerTokenException){
            ResponseResult.exceptionResponse(response,"令牌无效或已过期");
        }else{
            ResponseResult.exceptionResponse(response,authException);
        }
    }
}

上面 MyAuthenticationEntryPoint 实现类,重写 AuthenticationEntryPoint 接口的方法。如果异常是 InsufficientAuthenticationException 类型,则说明是访问了受保护的资源,但没带上令牌(token),然后根据请求头的 accept 信息判断请求来源,如果 accept 是"text/html",则通过LoginUrlAuthenticationEntryPoint 将请求跳转到“login”登录页,否则通过 ResponseResult 提示“需要带上令牌进行访问”。如果异常是 InvalidBearerTokenException 类型,则通过 ResponseResult提示“令牌无效或已过期”。如果是其他异常类型,通过 ResponseResult 将原始异常信息输出。

将 MyAuthenticationEntryPoint 分别添加到 AuthorizationServerConfig、DefaultSecurityConfig 类中,

AuthorizationServerConfig 类中异常配置。

//设置登录地址,需要进行认证的请求被重定向到该地址
        http
                .exceptionHandling((exceptions) -> exceptions
                        .defaultAuthenticationEntryPointFor(
                                new LoginUrlAuthenticationEntryPoint("/login"),
                                new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
                        )
                        .authenticationEntryPoint(new MyAuthenticationEntryPoint())
                )
                .oauth2ResourceServer(oauth2ResourceServer ->
                        oauth2ResourceServer.jwt(Customizer.withDefaults()));

DefaultSecurityConfig 类中异常配置。

http
                .oauth2ResourceServer((resourceServer) -> resourceServer
                        .jwt(Customizer.withDefaults())
                        .authenticationEntryPoint(new MyAuthenticationEntryPoint())
                );

3.4. 自定义AuthenticationEntryPoint测试

3.4.1. 测试用户名不存在

postman 输入不存在的用户名请求获取令牌(token),结果如下。

Spring Authorization Server (十)自定义异常_自定义异常_12

3.4.2. 测试密码不正确

postman 输入错误的密码请求获取令牌(token),结果如下。

Spring Authorization Server (十)自定义异常_Authorization Server_13


3.4.3. 测试请求受保护的资源未带令牌

postman 访问 http://localhost:9000/messages3 接口,不带令牌,结果如下。

Spring Authorization Server (十)自定义异常_Authorization Server_14

3.4.4. 测试令牌已过期

postman 访问 http://localhost:9000/messages3 接口,带上已过期的令牌,结果如下。

Spring Authorization Server (十)自定义异常_Spring Boot3_15

3.5. 自定义AccessDeniedHandler

新建 AccessDeniedHandler 的实现类,取名 MyAccessDeniedHandler,代码如下。

public class MyAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        if(request.getUserPrincipal() instanceof AbstractOAuth2TokenAuthenticationToken){
            ResponseResult.exceptionResponse(response,"权限不足");
        }else {
            ResponseResult.exceptionResponse(response,accessDeniedException);
        }
    }
}

上面 MyAccessDeniedHandler 实现类,重写 AccessDeniedHandler 接口的方法,如果用户凭据是 AbstractOAuth2TokenAuthenticationToken 类型,则异常信息提示“权限不足”,否则输出原始提示信息。

将 MyAccessDeniedHandler 分别添加到 AuthorizationServerConfig、DefaultSecurityConfig 类中,

AuthorizationServerConfig 类中异常配置。

//设置登录地址,需要进行认证的请求被重定向到该地址
        http
                .exceptionHandling((exceptions) -> exceptions
                        .defaultAuthenticationEntryPointFor(
                                new LoginUrlAuthenticationEntryPoint("/login"),
                                new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
                        )
                        .authenticationEntryPoint(new MyAuthenticationEntryPoint())
                        .accessDeniedHandler(new MyAccessDeniedHandler())
                )
                .oauth2ResourceServer(oauth2ResourceServer ->
                        oauth2ResourceServer.jwt(Customizer.withDefaults()));

DefaultSecurityConfig 类中异常配置。

http
                .oauth2ResourceServer((resourceServer) -> resourceServer
                        .jwt(Customizer.withDefaults())
                        .authenticationEntryPoint(new MyAuthenticationEntryPoint())
                        .accessDeniedHandler(new MyAccessDeniedHandler())
                );

3.6. 自定义AccessDeniedHandler测试

3.6.1. 测试请求受保护的资源令牌权限不足

postman 获取 token 如下。

Spring Authorization Server (十)自定义异常_OAuth2.1_16

将 access_token 使用 jwt 解析如下。

Spring Authorization Server (十)自定义异常_自定义异常_17

通过 jwt 解析可以看到,access_token 拥有的权限为 address email phone USER,而访问“http://spring-oauth-server:9000/messages3”接口需要 Message 权限,将 access_token 拿去访问 http://spring-oauth-server:9000/messages3 接口,结果如下。

Spring Authorization Server (十)自定义异常_Authorization Server_18

3.7. 自定义AuthenticationFailureHandler

上面的 AuthenticationEntryPoint、AccessDeniedHandler 在认证和授权过程中,足以捕获大多数的异常,但是对于有些异常还是鞭长莫及,例如:OAuth2AuthenticationException。我们把密码校验的异常抛出改成 OAuth2AuthenticationException,如下所示。

Spring Authorization Server (十)自定义异常_Spring Security_19

postman 输入错误的密码请求获取令牌(token),结果如下。

Spring Authorization Server (十)自定义异常_Spring Boot3_20

此时响应到 Body 的异常信息格式,并不是我们 ResponseResult 指定的 json 格式。此时,我们需要通过自定义实现 AuthenticationFailureHandler 来处理捕获的异常。

新建 AuthenticationFailureHandler 的实现类,取名为 MyAuthenticationFailureHandler,代码如下。

public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        ResponseResult.exceptionResponse(response,exception);
    }
}

将 MyAuthenticationFailureHandler 设置到 AuthorizationServerConfig 类中,设置如下。

.tokenEndpoint(tokenEndpoint->{
    tokenEndpoint.errorResponseHandler(new MyAuthenticationFailureHandler());
})

postman 输入错误的密码重新请求获取令牌(token),结果如下。

Spring Authorization Server (十)自定义异常_Spring Boot3_21

MyAuthenticationFailureHandler 的使命到此还未结束,postman 输入错误的客户端 id,重新请求获取令牌(token),结果如下。

Spring Authorization Server (十)自定义异常_Authorization Server_22

还要对客户端的校验异常进行捕获处理,AuthorizationServerConfig 配置类加入如下配置。

.clientAuthentication(clientAuthentication->{
    clientAuthentication.errorResponseHandler(new MyAuthenticationFailureHandler());
})

postman 输入错误的客户端 id,重新请求获取令牌(token),结果如下。

Spring Authorization Server (十)自定义异常_Spring Boot3_23

到此,MyAuthenticationFailureHandler 才完成他的使命。

3.8. 自定义ExceptionTranslationFilter

ExceptionTranslationFilter 只有处理 AuthenticationException、AccessDeniedException 的能力,但如果认证过程中出现 ArithmeticException 异常,会有两次经过 ExceptionTranslationFilter,ArithmeticException 异常首次经过 ExceptionTranslationFilter 时,由于不是 ExceptionTranslationFilter 的处理范围,因此 ArithmeticException 会被抛出去,丢给其他过滤器处理,当 ExceptionTranslationFilter 再次接到异常时,ArithmeticException 已经变成了 AccessDeniedException 异常,最后 ArithmeticException 异常被当作 AccessDeniedException 输出。

我们可以在 AuthorizationServerConfig 类中下面处理权限的地方加上“int a= 1/0;”的代码,进行调试一下。

Spring Authorization Server (十)自定义异常_Spring Boot3_24

postman 向 http://spring-oauth-server:9000/oauth2/token 发起请求获取 token。

Spring Authorization Server (十)自定义异常_自定义异常_25

首次进来是“ java.lang.ArithmeticException: / by zero”异常,这个异常是正确的,然后放行, ExceptionTranslationFilter 通过 this.rethrow(var8) 将异常抛出去。

Spring Authorization Server (十)自定义异常_OAuth2.1_26

接着 ExceptionTranslationFilter 又捕获到了异常,此时,异常已经变成“org.springframework.security.access.AccessDeniedException: Access Denied”了,这个异常就进入了 ExceptionTranslationFilter 的处理范围,最后这个异常被输出给客户端。但这个异常不是真正的异常,“ java.lang.ArithmeticException: / by zero”才是正在的异常,客户端要输出“ java.lang.ArithmeticException: / by zero”才正确,上演了一出狸猫换太子。

Spring Authorization Server (十)自定义异常_Spring Boot3_27

为了解决这个问题,我们可以自定义一个异常过滤器叫 MyExceptionTranslationFilter,然后添加到过滤链中。

新建 MyExceptionTranslationFilter 过滤器,代码如下。

public class MyExceptionTranslationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            filterChain.doFilter(request, response);
        }catch (Exception e) {
            if (e instanceof AuthenticationException|| e instanceof AccessDeniedException) {
                throw e;
            }
            //非AuthenticationException、AccessDeniedException异常,则直接响应
            ResponseResult.exceptionResponse(response,e);
        }

    }
}

MyExceptionTranslationFilter 过滤器的作用是将捕获到的非 AuthenticationException、AccessDeniedException 异常直接按我们自定义的格式输出。

将 MyExceptionTranslationFilter 过滤器在 AuthorizationServerConfig 配置类中插入过滤链。

//设置登录地址,需要进行认证的请求被重定向到该地址
http
    .addFilterBefore(new MyExceptionTranslationFilter(), ExceptionTranslationFilter.class)
    .exceptionHandling((exceptions) -> exceptions
                       .defaultAuthenticationEntryPointFor(
                           new LoginUrlAuthenticationEntryPoint("/login"),
                           new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
                       )
                       .authenticationEntryPoint(new MyAuthenticationEntryPoint())
                       .accessDeniedHandler(new MyAccessDeniedHandler())
                      )
    .oauth2ResourceServer(oauth2ResourceServer ->
                          oauth2ResourceServer.jwt(Customizer.withDefaults()));

postman 向 http://spring-oauth-server:9000/oauth2/token 发起请求获取 token,结果如下。

Spring Authorization Server (十)自定义异常_Spring Boot3_28

4. 配置文件类代码

上面的几种情况都涉及到了配置类的修改,下面把 AuthorizationServerConfig、DefaultSecurityConfig 完整代码粘贴上来。

AuthorizationServerConfig 类代码。

package org.oauth.server.config;

import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import jakarta.annotation.Resource;
import org.apache.catalina.util.StandardSessionIdGenerator;
import org.oauth.server.authentication.device.DeviceClientAuthenticationConverter;
import org.oauth.server.authentication.device.DeviceClientAuthenticationProvider;
import org.oauth.server.authentication.mobile.MobileGrantAuthenticationConverter;
import org.oauth.server.authentication.mobile.MobileGrantAuthenticationProvider;
import org.oauth.server.authentication.oidc.MyOidcUserInfoAuthenticationConverter;
import org.oauth.server.authentication.oidc.MyOidcUserInfoAuthenticationProvider;
import org.oauth.server.authentication.oidc.MyOidcUserInfoService;
import org.oauth.server.authentication.password.PasswordGrantAuthenticationConverter;
import org.oauth.server.authentication.password.PasswordGrantAuthenticationProvider;
import org.oauth.server.filter.MyExceptionTranslationFilter;
import org.oauth.server.handler.MyAccessDeniedHandler;
import org.oauth.server.handler.MyAuthenticationEntryPoint;
import org.oauth.server.handler.MyAuthenticationFailureHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.authorization.*;
import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.token.*;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.ExceptionTranslationFilter;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;

import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Instant;
import java.util.Collection;
import java.util.Date;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;

/**
 * @author Rommel
 * @version 1.0
 * @date 2023/7/10-16:34
 * @description TODO
 */
@Configuration
public class AuthorizationServerConfig {

    private static final String CUSTOM_CONSENT_PAGE_URI = "/oauth2/consent";
    @Resource
    private UserDetailsService userDetailsService;
    @Resource
    private MyOidcUserInfoService myOidcUserInfoService;


    /**
     * Spring Authorization Server 相关配置
     * 此处方法与下面defaultSecurityFilterChain都是SecurityFilterChain配置,配置的内容有点区别,
     * 因为Spring Authorization Server是建立在Spring Security 基础上的,defaultSecurityFilterChain方法主要
     * 配置Spring Security相关的东西,而此处authorizationServerSecurityFilterChain方法主要配置OAuth 2.1和OpenID Connect 1.0相关的东西
     */
    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http,RegisteredClientRepository registeredClientRepository,
                                                                      AuthorizationServerSettings authorizationServerSettings,
                                                                      OAuth2AuthorizationService authorizationService,
                                                                      OAuth2TokenGenerator<?> tokenGenerator)
            throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

        //AuthenticationConverter(预处理器),尝试从HttpServletRequest提取客户端凭据,用以构建OAuth2ClientAuthenticationToken实例。
        DeviceClientAuthenticationConverter deviceClientAuthenticationConverter =
                new DeviceClientAuthenticationConverter(
                        authorizationServerSettings.getDeviceAuthorizationEndpoint());
        //AuthenticationProvider(主处理器),用于验证OAuth2ClientAuthenticationToken。
        DeviceClientAuthenticationProvider deviceClientAuthenticationProvider =
                new DeviceClientAuthenticationProvider(registeredClientRepository);

        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
                .deviceAuthorizationEndpoint(deviceAuthorizationEndpoint ->
                        //设置用户码校验地址
                        deviceAuthorizationEndpoint.verificationUri("/activate")
                )
                .deviceVerificationEndpoint(deviceVerificationEndpoint ->
                        //设置授权页地址
                        deviceVerificationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI)
                )
                .clientAuthentication(clientAuthentication ->
                        //设置AuthenticationConverter(预处理器)和AuthenticationProvider(主处理器)
                        clientAuthentication
                                .authenticationConverter(deviceClientAuthenticationConverter)
                                .authenticationProvider(deviceClientAuthenticationProvider)
                )
                .authorizationEndpoint(authorizationEndpoint ->
                        authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI))
                //设置自定义密码模式
                .tokenEndpoint(tokenEndpoint ->
                        tokenEndpoint
                                .accessTokenRequestConverter(
                                        new PasswordGrantAuthenticationConverter())
                                .authenticationProvider(
                                        new PasswordGrantAuthenticationProvider(
                                                authorizationService, tokenGenerator)))
                //设置自定义手机验证码模式
                .tokenEndpoint(tokenEndpoint ->
                        tokenEndpoint
                                .accessTokenRequestConverter(
                                        new MobileGrantAuthenticationConverter())
                                .authenticationProvider(
                                        new MobileGrantAuthenticationProvider(
                                                authorizationService, tokenGenerator)))
                .tokenEndpoint(tokenEndpoint->{
                    tokenEndpoint.errorResponseHandler(new MyAuthenticationFailureHandler());
                })
                .clientAuthentication(clientAuthentication->{
                    clientAuthentication.errorResponseHandler(new MyAuthenticationFailureHandler());
                })
                //开启OpenID Connect 1.0(其中oidc为OpenID Connect的缩写)。
                //.oidc(Customizer.withDefaults());
                //自定义oidc
                .oidc(oidcCustomizer->{
                    oidcCustomizer.userInfoEndpoint(userInfoEndpointCustomizer->{
                        userInfoEndpointCustomizer.userInfoRequestConverter(new MyOidcUserInfoAuthenticationConverter(myOidcUserInfoService));
                        userInfoEndpointCustomizer.authenticationProvider(new MyOidcUserInfoAuthenticationProvider(authorizationService));
                    });
                });

        //设置登录地址,需要进行认证的请求被重定向到该地址
        http
                .addFilterBefore(new MyExceptionTranslationFilter(), ExceptionTranslationFilter.class)
                .exceptionHandling((exceptions) -> exceptions
                        .defaultAuthenticationEntryPointFor(
                                new LoginUrlAuthenticationEntryPoint("/login"),
                                new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
                        )
                        .authenticationEntryPoint(new MyAuthenticationEntryPoint())
                        .accessDeniedHandler(new MyAccessDeniedHandler())
                )
                .oauth2ResourceServer(oauth2ResourceServer ->
                        oauth2ResourceServer.jwt(Customizer.withDefaults()));

        return  http.build();
    }


    /**
     * 客户端信息
     * 对应表:oauth2_registered_client
     */
    @Bean
    public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
        return new JdbcRegisteredClientRepository(jdbcTemplate);
    }


    /**
     * 授权信息
     * 对应表:oauth2_authorization
     */
    @Bean
    public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
        return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
    }

    /**
     * 授权确认
     *对应表:oauth2_authorization_consent
     */
    @Bean
    public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
        return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }


    /**
     *配置 JWK,为JWT(id_token)提供加密密钥,用于加密/解密或签名/验签
     * JWK详细见:https://datatracker.ietf.org/doc/html/draft-ietf-jose-json-web-key-41
     */
    @Bean
    public JWKSource<SecurityContext> jwkSource() {
        KeyPair keyPair = generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        RSAKey rsaKey = new RSAKey.Builder(publicKey)
                .privateKey(privateKey)
                .keyID(UUID.randomUUID().toString())
                .build();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return new ImmutableJWKSet<>(jwkSet);
    }

    /**
     *生成RSA密钥对,给上面jwkSource() 方法的提供密钥对
     */
    private static KeyPair generateRsaKey() {
        KeyPair keyPair;
        try {
            KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
            keyPairGenerator.initialize(2048);
            keyPair = keyPairGenerator.generateKeyPair();
        }
        catch (Exception ex) {
            throw new IllegalStateException(ex);
        }
        return keyPair;
    }

    /**
     * 配置jwt解析器
     */
    @Bean
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }

    /**
     *配置认证服务器请求地址
     */
    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        //什么都不配置,则使用默认地址
        return AuthorizationServerSettings.builder().build();
    }

    /**
     *配置token生成器
     */
    @Bean
    OAuth2TokenGenerator<?> tokenGenerator(JWKSource<SecurityContext> jwkSource) {
        JwtGenerator jwtGenerator = new JwtGenerator(new NimbusJwtEncoder(jwkSource));
        jwtGenerator.setJwtCustomizer(jwtCustomizer(myOidcUserInfoService));
        OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();
        OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();
        return new DelegatingOAuth2TokenGenerator(
                jwtGenerator, accessTokenGenerator, refreshTokenGenerator);
    }

    @Bean
    public OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer(MyOidcUserInfoService myOidcUserInfoService) {

        return context -> {
            JwsHeader.Builder headers = context.getJwsHeader();
            JwtClaimsSet.Builder claims = context.getClaims();
            if (context.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) {
                // Customize headers/claims for access_token
                claims.claims(claimsConsumer->{
                    UserDetails userDetails = userDetailsService.loadUserByUsername(context.getPrincipal().getName());
                    claimsConsumer.merge("scope",userDetails.getAuthorities(),(scope,authorities)->{
                        Set<String> scopeSet = (Set<String>)scope;
                        Set<String> cloneSet = scopeSet.stream().map(String::new).collect(Collectors.toSet());
                                Collection<SimpleGrantedAuthority> simpleGrantedAuthorities = ( Collection<SimpleGrantedAuthority>)authorities;
                        simpleGrantedAuthorities.stream().forEach(simpleGrantedAuthority -> {
                            if(!cloneSet.contains(simpleGrantedAuthority.getAuthority())){
                                cloneSet.add(simpleGrantedAuthority.getAuthority());
                            }
                        });
                        return cloneSet;
                    });
                });

            } else if (context.getTokenType().getValue().equals(OidcParameterNames.ID_TOKEN)) {
                // Customize headers/claims for id_token
                claims.claim(IdTokenClaimNames.AUTH_TIME, Date.from(Instant.now()));
                StandardSessionIdGenerator standardSessionIdGenerator = new StandardSessionIdGenerator();
                claims.claim("sid", standardSessionIdGenerator.generateSessionId());
            }
        };
    }

}

DefaultSecurityConfig 类代码。

package org.oauth.server.config;

import org.oauth.server.handler.MyAccessDeniedHandler;
import org.oauth.server.handler.MyAuthenticationEntryPoint;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

/**
 * @author Rommel
 * @version 1.0
 * @date 2023/7/15-23:21
 * @description TODO
 */
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(jsr250Enabled = true, securedEnabled = true)
public class DefaultSecurityConfig {


    /**
     *Spring Security 过滤链配置(此处是纯Spring Security相关配置)
     */
    @Bean
    @Order(2)
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
            throws Exception {
//        http
//                //设置所有请求都需要认证,未认证的请求都被重定向到login页面进行登录
//                .authorizeHttpRequests((authorize) -> authorize
//                        .anyRequest().authenticated()
//                )
//                // 由Spring Security过滤链中UsernamePasswordAuthenticationFilter过滤器拦截处理“login”页面提交的登录信息。
//                .formLogin(Customizer.withDefaults());
        http
                .authorizeHttpRequests(authorize ->
                        authorize
                                .requestMatchers("/assets/**", "/webjars/**", "/login").permitAll()
                                .anyRequest().authenticated()
                )
                .formLogin(formLogin ->
                        formLogin
                                .loginPage("/login")
                );
        http
                .oauth2ResourceServer((resourceServer) -> resourceServer
                        .jwt(Customizer.withDefaults())
                        .authenticationEntryPoint(new MyAuthenticationEntryPoint())
                        .accessDeniedHandler(new MyAccessDeniedHandler())

                );

        return http.build();
    }


    /**
     *设置用户信息,校验用户名、密码
     * 这里或许有人会有疑问,不是说OAuth 2.1已经移除了密码模式了码?怎么这里还有用户名、密码登录?
     * 例如:某平台app支持微信登录,用户想使用微信账号登录登录该平台app,则用户需先登录微信app,
     * 此处代码的操作就类似于某平台app跳到微信登录界面让用户先登录微信,然后微信校验用户提交的用户名、密码,
     * 登录了微信才对某平台app进行授权,对于微信平台来说,某平台的app就是OAuth 2.1中的客户端。
     * 其实,这一步是Spring Security的操作,纯碎是认证平台的操作,是脱离客户端(第三方平台)的。
     */
//    @Bean
//    public UserDetailsService userDetailsService() {
//        UserDetails userDetails = User.withDefaultPasswordEncoder()
//                .username("user")
//                .password("password")
//                .roles("USER")
//                .build();
//        //基于内存的用户数据校验
//        return new InMemoryUserDetailsManager(userDetails);
//    }

}

5. 授权码模式测试

上面对异常的处理都是使用 postman 通过 api 进行操作的,现在测试一下授权码流程是否正常。

在浏览器上输入 http://localhost:9000/oauth2/authorize?response_type=code&client_id=oidc-client&scope=profile openid&redirect_uri=http://www.baidu.com 地址,进入到用户登录界面。

Spring Authorization Server (十)自定义异常_自定义异常_29

输入用户名:user,密码:123456,进入到授权页面。

Spring Authorization Server (十)自定义异常_Spring Security_30

勾选授权范围,提交授权确认,则成功返回授权码。

Spring Authorization Server (十)自定义异常_OAuth2.1_31

使用授权码获取 token,返回如下成功结果。

Spring Authorization Server (十)自定义异常_OAuth2.1_32

6. 总结

本篇先举例演示了用户名不存在、密码不正确、请求受保护的资源未带令牌、请求受保护的资源令牌权限不足等框架默认的异常输出情况。接着介绍了框架中 ExceptionTranslationFilter 过滤器对AuthenticationException、AccessDeniedException 的处理逻辑。然后实现了自定义的AuthenticationException、AccessDeniedException、AuthenticationFailureHandler、ExceptionTranslationFilter,并一一给出了相应的测试以验证,最后测试了一下授权码模式的整个流程是因为本篇章所增加的自定义异常而收到影响。


本篇代码在 spring-oauth-pkce-server 目录下:链接地址