В данной статье я хотел бы поделиться, на мой взгляд, удачным опытом написания своего велосипеда для аутентификации пользователя в REST API с использованием JWT.
Это не замена Spring Security, но этот способ хорошо себя показывает в продакшене на протяжении вот уже более двух лет.

Постараюсь описать весь процесс как можно подробнее, начиная от генерации ключа для JWT до контроллера, чтобы даже незнакомому с JWT стало все понятно.



Содержание


  • Предыстория
  • Генерацию ключа
  • Создание Spring проекта
  • TokenHandler
  • Аннотация и обработчик
  • Обработка AuthenticationException
  • Контроллер

0. Предыстория


Для начала, хочу рассказать что именно меня побудило реализовать данный способ аутентификации клиента и почему не использовал Spring Security. Кому не интересно, может сразу перейти к следующей главе.

К тому моменту я работал в небольшой фирме, которая занимается разработкой сайтов. Это было мое первое рабочее место в данной сфере, поэтому толком ничего и не знал. Где-то через месяц работы сказали, что будет новый проект и что нужно подготовить базовый функционал для него. Решил посмотреть подробнее как этот процесс был реализован в уже существующих проектах. К моему сожалению, там было все не так уж и радостно.

В каждом методе контроллера, там где нужно было вытащить авторизованного пользователя было примерно следующее

@RequestMapping(value = "/endpoint", method = RequestMethod.GET)
 public Response endpoint() {
     User user = getUser(); // Метод базового класса
     if (null == user)
         return new ErrorResponse.Builder(Error.AUTHENTICATION_ERROR).build();

     // Логика контроллера
 }

И так было везде… Добавление нового эндпоинта начиналось с того, что копировался этот кусок кода. Мне показалось это немного странным и совершенно неудобным в использовании.

Для решения этой проблемы я отправился в гугл. Возможно я как-то не так искал, но подходящего решения я не нашел. Везде были инструкции по настройке Spring Security.

Объясню почему я не хотел использовать Spring Security. Он мне показался слишком сложным и его как-то не очень удобно использовать в REST. Да и в методах обработки endpoint'а все равно, наверное, придется доставать юзера из контекста. Возможно я не прав, так как не сильно в нем разбирался, но статья в любом случае не об этом.

Мне нужно было что-то простое и удобное в использовании. Пришла идея сделать это через аннотацию.

Идея заключается в том, что в каждый метод контроллера, где нужна авторизация, мы инжектим нашего юзера. И все. Получается, что внутри метода контроллера уже будет авторизованный юзер и он будет != null (за исключением случаев, когда авторизация не обязательная).

С причинами создания данного велосипеда разобрались. Теперь перейдем к практике.

1. Генерацию ключа


Для начала нам нужно сгенерировать ключ, которым будет шифроваться минимально необходимая информация о юзере.

Для работы на java с jwt есть очень удобная библиотека.

На гитхабе есть все инструкции как работать с jwt, но что бы упростить процесс, приведу пример ниже.

Для генерации ключа создадим обычный maven проект и добавим следующие зависимости

dependencies
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.2</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.2</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.2</version>
    <scope>runtime</scope>
</dependency>


И класс, который будет генерировать secret

SecretGenerator.java
package jwt;

import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.security.Keys;

import javax.crypto.SecretKey;

public class SecretGenerator {

    public static void main(String[] args) {
        SecretKey secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS512);
        String secretString = Encoders.BASE64.encode(secretKey.getEncoded());
        System.out.println(secretString);
    }
}


На выходе получим секретный ключ, который будем в дальнейшем использовать.

2. Создание Spring проекта


Процесс создания описывать не буду, так как на эту тему существует множество статей и туториалов. Да и на официальном сайте Spring'а есть initializer, где в два клика можно создать минимальный проект.

Оставлю только итоговый pom файл

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.2.RELEASE</version>
    </parent>

    <groupId>org.website</groupId>
    <artifactId>backend</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <properties>
        <java.version>14</java.version>
        <start-class>org.website.BackendWebsiteApplication</start-class>
    </properties>

    <profiles>
        <profile>
            <id>local</id>
            <properties>
                <activatedProperties>local</activatedProperties>
            </properties>
            <activation>
                <activeByDefault>true</activeByDefault>
            </activation>
        </profile>
    </profiles>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

    <dependencies>
        <!--*******SPRING*******-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-logging</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <!--*******JWT*******-->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.11.2</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.11.2</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>0.11.2</version>
            <scope>runtime</scope>
        </dependency>

        <!--*******OTHER*******-->
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <version>42.2.14</version>
        </dependency>
        <dependency>
            <groupId>org.liquibase</groupId>
            <artifactId>liquibase-core</artifactId>
            <version>4.0.0</version>
        </dependency>
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>29.0-jre</version>
        </dependency>
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.8.6</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.11</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.12</version>
            <scope>provided</scope>
        </dependency>

        <!--*******TEST*******-->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.hamcrest</groupId>
            <artifactId>hamcrest</artifactId>
            <version>2.2</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

</project>


После создания проекта копируем ранее созданный ключ в application.properties

app.api.jwtEncodedSecretKey=teTN1EmB5XADI5iV4daGVAQhBlTwLMAE+LlXZp1JPI2PoQOpgVksRqe79EGOc5opg+AmxOOmyk8q1RbfSWcOyg==

3. TokenHandler


Нам понадобится сервис для генерации и расшифровки токенов.

В токене будет минимум информации о юзере(только его id) и время истечения токена. Для этого создадим интерфейсы.

Для передачи времени жизни токена.

Expiration.java
package org.website.jwt;

import java.time.LocalDateTime;
import java.util.Optional;

public interface Expiration {

    Optional<LocalDateTime> getAuthTokenExpire();
}


И для передачи ID. Его будет имплементировать сущность юзера

CreateBy.java
package org.website.jwt;

public interface CreateBy {

    Long getId();
}


Так же создадим дефолтную имплементацию для интерфейса Expiration. По умолчанию токен будет жить 24 часа.

DefaultExpiration.java
package org.website.jwt;

import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.Optional;

@Component
public class DefaultExpiration implements Expiration {

    @Override
    public Optional<LocalDateTime> getAuthTokenExpire() {
        return Optional.of(LocalDateTime.now().plusHours(24));
    }
}


Добавим пару вспомогательных классов.

GeneratedTokenInfo — для информации о сгенерированном токене.
TokenInfo — для информации о пришедшем к нам токене.

GeneratedTokenInfo.java
package org.website.jwt;

import java.time.LocalDateTime;
import java.util.Optional;

public class GeneratedTokenInfo {

    private final String token;
    private final LocalDateTime expiration;

    public GeneratedTokenInfo(String token, LocalDateTime expiration) {
        this.token = token;
        this.expiration = expiration;
    }

    public String getToken() {
        return token;
    }

    public LocalDateTime getExpiration() {
        return expiration;
    }

    public Optional<String> getSignature() {
        if (null != this.token && this.token.length() >= 3)
            return Optional.of(this.token.split("\\.")[2]);

        return Optional.empty();
    }
}



TokenInfo.java
package org.website.jwt;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import lombok.NonNull;

import java.time.LocalDateTime;
import java.time.ZoneId;

public class TokenInfo {

    private final Jws<Claims> claimsJws;

    private final String signature;
    private final Claims body;
    private final Long userId;
    private final LocalDateTime expiration;

    private TokenInfo() {
        throw new UnsupportedOperationException();
    }

    private TokenInfo(@NonNull final Jws<Claims> claimsJws,
                      @NonNull final String signature,
                      @NonNull final Claims body,
                      @NonNull final Long userId,
                      @NonNull final LocalDateTime expiration) {
        this.claimsJws = claimsJws;
        this.signature = signature;
        this.body = body;
        this.userId = userId;
        this.expiration = expiration;
    }

    public static TokenInfo fromClaimsJws(@NonNull final Jws<Claims> claimsJws) {
        final Claims body = claimsJws.getBody();
        return new TokenInfo(
                claimsJws,
                claimsJws.getSignature(),
                body,
                Long.parseLong(body.getId()),
                body.getExpiration().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime());
    }

    public Jws<Claims> getClaimsJws() {
        return claimsJws;
    }

    public String getSignature() {
        return signature;
    }

    public Claims getBody() {
        return body;
    }

    public Long getUserId() {
        return userId;
    }

    public LocalDateTime getExpiration() {
        return expiration;
    }
}


Теперь сам TokenHandler. Он будет генерировать токен при авторизации юзера, а так же извлекать информацию о токене с которым пришел ранее уже авторизованный юзер.

TokenHandler.java
package org.website.jwt;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.sql.Date;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Base64;
import java.util.Optional;

@Service
@Slf4j
public class TokenHandler {

    @Value("${app.api.jwtEncodedSecretKey}")
    private String jwtEncodedSecretKey;

    private final DefaultExpiration defaultExpiration;

    private SecretKey secretKey;

    @Autowired
    public TokenHandler(final DefaultExpiration defaultExpiration) {
        this.defaultExpiration = defaultExpiration;
    }

    @PostConstruct
    private void postConstruct() {
        byte[] decode = Base64.getDecoder().decode(jwtEncodedSecretKey);
        this.secretKey = new SecretKeySpec(decode, 0, decode.length, "HmacSHA512");
    }

    public Optional<GeneratedTokenInfo> generateToken(CreateBy createBy, Expiration expire) {
        if (null == expire || expire.getAuthTokenExpire().isEmpty())
            expire = this.defaultExpiration;

        try {
            final LocalDateTime expireDateTime = expire.getAuthTokenExpire().get().withNano(0);

            String compact = Jwts.builder()
                    .setId(String.valueOf(createBy.getId()))
                    .setExpiration(Date.from(expireDateTime.atZone(ZoneId.systemDefault()).toInstant()))
                    .signWith(this.secretKey)
                    .compact();

            return Optional.of(new GeneratedTokenInfo(compact, expireDateTime));
        } catch (Exception e) {
            log.error("Error generate new token. CreateByID: {}; Message: {}", createBy.getId(), e.getMessage());
        }
        return Optional.empty();
    }

    public Optional<GeneratedTokenInfo> generateToken(CreateBy createBy) {
        return this.generateToken(createBy, this.defaultExpiration);
    }

    public Optional<TokenInfo> extractTokenInfo(final String token) {
        try {
            Jws<Claims> claimsJws = Jwts.parserBuilder()
                    .setSigningKey(this.secretKey)
                    .build()
                    .parseClaimsJws(token);
            return Optional.ofNullable(claimsJws).map(TokenInfo::fromClaimsJws);
        } catch (Exception e) {
            log.error("Error extract token info. Message: {}", e.getMessage());
        }

        return Optional.empty();
    }

}


Заострять внимание не буду, так как с этим все должно быть понятно.

4. Аннотация и обработчик


Итак, после всех подготовительных работ, перейдем к самому интересному. Как уже было сказано ранее, нам нужна аннотация, которая будет инжектиться в методы контроллера, где нужен авторизованный пользователь.

Создаем аннотацию со следующим кодом

AuthUser.java
package org.website.annotation;

import java.lang.annotation.*;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AuthUser {
    boolean required() default true;
}


Ранее было сказано, что авторизация может быть необязательной. Как раз для этого и нужен метод required в аннотации. В случае если авторизация для конкретного метода необязательна и если пришедший пользователь действительно не авторизован, то в метод заинжектится null. Но к этому мы будем готовы.

Аннотация создана, но еще нужен handler, который и будет доставать из запроса токен, получать из базы пользователя и пробрасывать его в метод контроллера. Для таких случаев у Spring'а есть интерфейс HandlerMethodArgumentResolver. Его и будем имплементировать.

Создаем класс AuthUserHandlerMethodArgumentResolver, который имплементит указанный выше интерфейс.

AuthUserHandlerMethodArgumentResolver.java
package org.website.annotation.handler;

import org.springframework.core.MethodParameter;
import org.springframework.lang.NonNull;
import org.springframework.web.bind.support.WebArgumentResolver;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import org.springframework.web.util.WebUtils;
import org.website.annotation.AuthUser;
import org.website.annotation.exception.AuthenticationException;
import org.website.domain.User;
import org.website.domain.UserJwtSignature;
import org.website.jwt.TokenHandler;
import org.website.service.repository.UserJwtSignatureService;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import java.util.Objects;
import java.util.Optional;

public class AuthUserHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {

    private final String AUTH_COOKIE_NAME;
    private final String AUTH_HEADER_NAME;

    private final TokenHandler tokenHandler;

    private final UserJwtSignatureService userJwtSignatureService;

    public AuthUserHandlerMethodArgumentResolver(final String authTokenCookieName,
                                                 final String authTokenHeaderName,

                                                 final TokenHandler tokenHandler,

                                                 final UserJwtSignatureService userJwtSignatureService) {
        this.AUTH_COOKIE_NAME = authTokenCookieName;
        this.AUTH_HEADER_NAME = authTokenHeaderName;

        this.tokenHandler = tokenHandler;

        this.userJwtSignatureService = userJwtSignatureService;
    }

    @Override
    public boolean supportsParameter(@NonNull final MethodParameter methodParameter) {
        return methodParameter.getParameterAnnotation(AuthUser.class) != null && methodParameter.getParameterType().equals(User.class);
    }

    @Override
    public Object resolveArgument(@NonNull final MethodParameter methodParameter,
                                  final ModelAndViewContainer modelAndViewContainer,
                                  @NonNull final NativeWebRequest nativeWebRequest,
                                  final WebDataBinderFactory webDataBinderFactory) throws Exception {
        if (!this.supportsParameter(methodParameter))
            return WebArgumentResolver.UNRESOLVED;

        // из аннотации достаем значение поля required
        final boolean required = Objects.requireNonNull(methodParameter.getParameterAnnotation(AuthUser.class)).required();

        // получаем HttpServletRequest из пришедшего запроса
        Optional<HttpServletRequest> httpServletRequestOptional = Optional.ofNullable(nativeWebRequest.getNativeRequest(HttpServletRequest.class));

        // пробуем достать токен из куки или из заголовка запроса
        Optional<UserJwtSignature> userJwtSignature =
                this.extractAuthTokenFromRequest(nativeWebRequest, httpServletRequestOptional.orElse(null))
                        .flatMap(tokenHandler::extractTokenInfo)
                        .flatMap(userJwtSignatureService::extractByTokenInfo);
        
        if (required) {
            // если пользователь должен быть обязательно авторизован проверяем авторизацию
            if (userJwtSignature.isEmpty() || null == userJwtSignature.get().getUser())
                // в случае если не авторизован выбрасываем исключение
                throw new AuthenticationException(httpServletRequestOptional.map(HttpServletRequest::getMethod).orElse(null),
                        httpServletRequestOptional.map(HttpServletRequest::getRequestURI).orElse(null));

            final User user = userJwtSignature.get().getUser();

            // возвращаем юзера в метод
            return this.appendCurrentSignature(user, userJwtSignature.get());
        } else {
            // если авторизация не обязательна, то либо возвращаем полученного юзера, либо null
            return this.appendCurrentSignature(userJwtSignature.map(UserJwtSignature::getUser).orElse(null),
                    userJwtSignature.orElse(null));
        }
    }

    private User appendCurrentSignature(User user, UserJwtSignature userJwtSignature) {
        Optional.ofNullable(user).ifPresent(u -> u.setCurrentSignature(userJwtSignature));
        return user;
    }

    private Optional<String> extractAuthTokenFromRequest(@NonNull final NativeWebRequest nativeWebRequest,
                                                         final HttpServletRequest httpServletRequest) {
        return Optional.ofNullable(httpServletRequest)
                .flatMap(this::extractAuthTokenFromRequestByCookie)
                .or(() -> this.extractAuthTokenFromRequestByHeader(nativeWebRequest));
    }

    private Optional<String> extractAuthTokenFromRequestByCookie(final HttpServletRequest httpServletRequest) {
        return Optional
                .ofNullable(httpServletRequest)
                .map(request -> WebUtils.getCookie(httpServletRequest, AUTH_COOKIE_NAME))
                .map(Cookie::getValue);
    }

    private Optional<String> extractAuthTokenFromRequestByHeader(@NonNull final NativeWebRequest nativeWebRequest) {
        return Optional.ofNullable(nativeWebRequest.getHeader(AUTH_HEADER_NAME));
    }
}


В конструкторе принимаем названия куки и хедера, в которых может передаваться токен. Я их вынес в application.properties

app.api.tokenKeyName=Auth-Token
app.api.tokenHeaderName=Auth-Token

Так же в конструкторе передается созданный ранее TokenHandler и UserJwtSignatureService.

UserJwtSignatureService рассматривать не будем, так как там стандартное извлечение пользователя из базы по его id и сигнатуре токена.

А вот код самого хендлера разберем подробнее.

supportsParameter — проверяется удовлетворяет ли метод необходимым требованиям.

resolveArgument — основной метод, внутри которого и происходит вся «магия».

Итак, что тут происходит:

  1. Достаем из нашей аннотации значение поля required
  2. Получаем HttpServletRequest из пришедшего запроса
  3. Пробуем достать токен из куки или из хедеров
  4. Парсим его, в случае если он есть
  5. Достаем из базы пользователя по токену
  6. Далее смотрим на значение поля required, и если оно обязательное, то проверяем наличие полученного пользователя.
    В случае, если мы не достали пользователя, то бросаем исключение(для чего это надо, объясню в следующем разделе).
    Если же удалось найти пользователя по токену, то возвращаем его, и тем самым, он будет заинжекчен в наш метод.
  7. В случае если авторизация необязательная, о чем свидетельствует поле required, возвращаем либо полученного юзера, либо null

Обработчик аннотации создан. Но это еще не все. Его надо зарегистрировать, чтобы Spring о нем узнал. Тут все просто. Создаем конфигурационный файл, который имплементирует интерфейс Spring'а WebMvcConfigurer и переопределяем метод addArgumentResolvers

WebMvcConfig.java
package org.website.configuration;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.website.annotation.handler.AuthUserHandlerMethodArgumentResolver;
import org.website.jwt.TokenHandler;
import org.website.service.repository.UserJwtSignatureService;

import java.util.List;

@Configuration
@EnableWebMvc
public class WebMvcConfig implements WebMvcConfigurer {

    @Value("${app.api.tokenKeyName}")
    private String tokenKeyName;

    @Value("${app.api.tokenHeaderName}")
    private String tokenHeaderName;

    private final TokenHandler tokenHandler;
    private final UserJwtSignatureService userJwtSignatureService;

    @Autowired
    public WebMvcConfig(final TokenHandler tokenHandler,
                        final UserJwtSignatureService userJwtSignatureService) {
        this.tokenHandler = tokenHandler;
        this.userJwtSignatureService = userJwtSignatureService;
    }

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new AuthUserHandlerMethodArgumentResolver(
                this.tokenKeyName,
                this.tokenHeaderName,
                this.tokenHandler,
                this.userJwtSignatureService));
    }
}


На этом написание аннотации заканчивается.

5. Обработка AuthenticationException


В предыдущем разделе, в обработчике аннотации, в случае если для метода контроллера авторизация обязательна, но пользователь не авторизован мы выбросили исключение AuthenticationException.

Теперь нужно добавить класс этого исключения и обработать его, чтобы вернуть пользователю json с нужной нам информацией.

AuthenticationException.java
package org.website.annotation.exception;

public class AuthenticationException extends Exception {

    public AuthenticationException(String requestMethod, String url) {
        super(String.format("%s - %s", requestMethod, url));
    }
}


И теперь сам обработчик исключения. Для того, чтобы обрабатывать возникшие исключения и отдавать пользователю не какую-то стандартную Spring'овую страницу об ошибке, а нужный нам json, в Spring'е есть аннотация ControllerAdvice.

Добавим класс обработки нашего эксепшена.

AuthenticationExceptionControllerAdvice.java
package org.website.controller.exception.handler;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.website.annotation.exception.AuthenticationException;
import org.website.http.response.Error;
import org.website.http.response.ErrorResponse;
import org.website.http.response.Response;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;

@ControllerAdvice
@Slf4j
public class AuthenticationExceptionControllerAdvice extends AbstractControllerAdvice {

    @Value("${app.api.tokenKeyName}")
    private String tokenKeyName;

    @ExceptionHandler({AuthenticationException.class})
    public Response authenticationException(HttpServletResponse response) {
        Cookie cookie = new Cookie(tokenKeyName, "");
        cookie.setPath("/");
        cookie.setMaxAge(0);
        response.addCookie(cookie);
        return new ErrorResponse.Builder(Error.AUTHENTICATION_ERROR).build();
    }
}


Теперь, в случае, если возникнет исключение AuthenticationException, оно будет перехвачено и пользователю вернется json с ошибкой AUTHENTICATION_ERROR

6. Контроллер


Теперь, собственно, ради чего все и затевалось. Создадим контроллер, в котором будет 3 метода:

  1. С обязательной авторизацией
  2. С необязательной авторизацией
  3. Регистрации нового пользователя. Минимальный код. Просто сохраняет пользователя в базу, без паролей. Который, так же, будет возвращать токен нового пользователя

TestAuthController.java
package org.website.controller;

import com.google.gson.JsonObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.website.annotation.AuthUser;
import org.website.domain.User;
import org.website.http.response.Response;
import org.website.http.response.SuccessResponse;
import org.website.jwt.GeneratedTokenInfo;
import org.website.service.repository.UserJwtSignatureService;
import org.website.service.repository.UserService;

import java.util.Optional;

@RestController
@RequestMapping("/test-auth")
public class TestAuthController {

    @Autowired
    private UserService userService;

    @Autowired
    private UserJwtSignatureService userJwtSignatureService;

    @RequestMapping(value = "/required", method = RequestMethod.GET)
    public Response required(@AuthUser final User user) {
        return new SuccessResponse.Builder(user).build();
    }

    @RequestMapping(value = "/not-required", method = RequestMethod.GET)
    public Response notRequired(@AuthUser(required = false) final User user) {
        JsonObject response = new JsonObject();

        if (null == user) {
            response.addProperty("message", "Hello guest!");
        } else {
            response.addProperty("message", "Hello " + user.getFirstName());
        }

        return new SuccessResponse.Builder(response).build();
    }

    @RequestMapping(value = "/sign-up", method = RequestMethod.GET)
    public Response signUp(@RequestParam String firstName) {
        User user = userService.save(User.builder().firstName(firstName).build());

        Optional<GeneratedTokenInfo> generatedTokenInfoOptional =
                userJwtSignatureService.generateNewTokenAndSaveToDb(user);

        return new SuccessResponse.Builder(user)
                .addPropertyToPayload("token", generatedTokenInfoOptional.get().getToken())
                .build();
    }
}


В методах required и notRequired мы вставляем нашу аннотацию.
В первом случае, если пользователь не авторизован — должен вернуться json с ошибкой, а если авторизован, то вернется информация о пользователе.

Во втором случае, если пользователь не авторизован, то вернется сообщение Hello guest!, а если авторизован, то вернется его имя.
Проверим, что все действительно работает.

Для начала проверим оба метода в качестве неавторизованного пользователя.

/required


/not-required


Все как и ожидалось. Там где авторизация была обязательной — вернулась ошибка, а во втором случае — сообщение Hello guest!.

Теперь зарегистрируемся и попробуем вызвать эти же методы, но уже с передачей токена в заголовках запроса.

/sign-up


В ответе вернулся токен, который можно использовать для тех запросов, где нужна авторизация.

Проверим это:

/required


/not-required


В первом случае возвращается просто информацию о пользователе. Во втором случае возвращается приветственное сообщение.

Работает!

7. Заключение


Данный метод не претендует на единственное правильное решение. Возможно, кому-то больше по душе использование Spring Security. Но, как уже было сказано в самом начале, этот метод проверен, удобен в использовании и очень хорошо работает.