0

Пишем Java веб-приложение на современном стеке. С нуля до микросервисной архитектуры. Часть 2

В прошлой статье, мы спроектировали и реализовали простой сервис BookStore.

В этой части мы попытаемся добавить безопасности в наше приложение — сделаем отдельный микросервис аутентификации/авторизации, а в нашем приложении BookStore запретим вызов методов неавторизованными пользователями. И хотя существуют готовые решения (например, Spring Security), мы напишем всё сами, чтобы разобрать принципы работы.

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

Сейчас у нас всего один сервис, которому требуется авторизация. Но в реальности таких сервисов может быть очень много. И при такой архитектуре нагрузка на сервис авторизации будет расти лавинообразно. Поэтому хорошо бы авторизовать пользователя однократно, выдав ему что-то, что может приниматься всеми другими системами без валидации на стороне авторизационного сервиса. Для таких целей можно воспользоваться JWT.

JWT (JSON Web Tokens)

JWT — это стандарт, основанный на JSON, для создания токенов доступа. Он состоит из трех частей: заголовка, тела и подписи:

В заголовке указывается тип токена и алгоритм подписи:

{
  "typ": "JWT",
  "alg": "HS256"
}

В тело записывается необходимая пользовательская информация (payload). Для аутентификации и авторизации это может быть id пользователя, его роль, время действия токена:

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true,
  "iat": 1636829229,
  "exp": 1636832829
}
  • sub — уникальный идентификатор пользователя (subject)
  • iat — время выпуска токена в unix-time формате (issued at)
  • exp — время действия токена (expiration time). Также в unix-time формате. После окончания действия токена он не должен приниматься вызываемой стороной

Завершающей частью токена является подпись. В нашем примере используется симметричный алгоритм HS256 — HMAC с хеш-функцией SHA-256. Это означает, что к исходному сообщению (заголовок + тело) добавляется секретный ключ, и от полученной строки берется хеш SHA-256.

Для того чтобы токен выглядел компактно заголовок и тело кодируются алгоритмом Base64-URL, разделяются точками и в конце добавляется подпись.

Основная идея JWT заключается в том, что подписанный токен нельзя подделать, т.к. любое изменение тела или заголовка приведет к невалидности подписи. Секретный ключ, которым подписывается токен, хранится в тайне на сервере. Принимающая токен сторона, зная секретный ключ, может с легкостью проверить подпись — взять тело и заголовок, вычислить HS256 и сравнить с присланным. Если они совпадают, то токен не модифицировался и можно доверять ему содержимому. Это позволяет реализовать следующую архитектуру нашей системы:

Пользователь делает запрос на авторизационный сервис, передавая свои аутентификационные данные (например, логин и пароль), а сервис формирует токен в виде JWT, в котором будет указан id пользователя и его права. Права определяют к каким данным, сервисам он будет иметь доступ. Если это администратор системы, то он может иметь больше прав, чем обычный пользователь.

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

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

Для того чтобы пользователь не вводил каждый раз логин и пароль, когда токен заканчивает свое действие, обычно при авторизации выписываются сразу два токена: access token и refresh token. Первый имеет короткий срок жизни и используется для доступа к ресурсам (скажем 5 минут), а второй более длинный (например неделю, месяц). Как только access token заканчивает свое действие, пользователь делает запрос на авторизационный сервис с refresh token, получая в ответ обновленные оба токена. Если пользователь был неактивен длительное время (больше чем срок действия refresh token), то ему придется заново аутентифицироваться, введя свой логин и пароль.

Бывают случаи, когда необходимо отозвать выданные токены. Это может потребоваться, когда токены скомпрометированы или когда пользователь хочет выйти из своего аккаунта на всех устройствах, где он уже входил и получил токены. Такая задача решается использованием черного списка, который хранится на сервере (например, в БД). Отозванные токены будут храниться в нем до истечения срока жизни. Объем такой базы будет намного меньше, чем БД с пользователями.

Auth-сервис

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

Пользователи будут храниться в БД. В идеале это должна быть отдельная БД, но в обучающих целях будем использовать ту же, что использовали в первой части. Опишем DAO пользователя:

@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "clients")
public class ClientEntity {
    @Id
    @Column(name = "client_id")
    private String clientId;
    private String hash;
}

Репозиторий будет выглядеть совсем просто:

public interface ClientRepository
        extends CrudRepository<ClientEntity, String> {
}

Т.к. мы будем реализовывать Client Credentials Flow в терминах протокола OAuth, то здесь client — это и есть пользователь. Соответственно clientId, clientSecret — аутентификационные данные пользователя. В открытом виде пароль пользователя хранить нельзя, поэтому будем хранить некий хеш, о котором будет написано ниже.

Опишем сервис, который будет регистрировать нового клиента и проверять его аутентификационные данные:

public interface ClientService {
    void register(String clientId, String clientSecret);
    void checkCredentials(String clientId, String clientSecret);
}

Для правильного хранения паролей в БД будем использовать Bcrypt. Это криптографическая хеш-функция, основанная на шифре Blowfish. Воспользуемся реализацией в библиотеке jBCrypt, добавим зависимость в проект:

dependencies {
    ...
    implementation group: 'org.mindrot', name: 'jbcrypt', version: '0.4'
}

Реализуем интерфейс ClientService:

@Service
@RequiredArgsConstructor
public class DefaultClientService implements ClientService {
    private final ClientRepository userRepository;

    @Override
    public void register(String clientId, String clientSecret) {
        if(userRepository.findById(clientId).isPresent())
            throw new RegistrationException(
                "Client with id: " + clientId + " already registered");

        String hash = BCrypt.hashpw(clientSecret, BCrypt.gensalt());
        userRepository.save(new ClientEntity(clientId, hash));
    }

    @Override
    public void checkCredentials(String clientId, String clientSecret) {
        Optional<ClientEntity> optionalUserEntity = userRepository
            .findById(clientId);
        if (optionalUserEntity.isEmpty())
            throw new LoginException(
                "Client with id: " + clientId + " not found");

        ClientEntity clientEntity = optionalUserEntity.get();

        if (!BCrypt.checkpw(clientSecret, clientEntity.getHash()))
            throw new LoginException("Secret is incorrect");
    }
}

При регистрации клиента мы генерируем соль вызовом метода BCrypt.gensalt() и используем её для вычисления хеша. В результате получаем строку, содержащую соль и хеш пароля. Полученное значение сохраняем в БД. Пример сгенерированного хеша:

$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
  • $2a$10$ — заголовок (алгоритм bcrypt и количество раундов хеширования)
  • N9qo8uLOickgx2ZMRZoMye — соль (16 байт)
  • IjZAgcfl7p92ldGxad68LJZdL17lhWy — хеш (24 байта)

Для проверки присланного значения clientSecret необходимо вызвать метод BCrypt.checkpw, передав значение, сохраненное в БД при регистрации.

Теперь нам нужно описать сервис формирования токенов JWT. В качестве библиотеки воспользуется реализацией от Auth0:

dependencies {
    ...
    implementation 'com.auth0:java-jwt:3.18.2'
}

Интерфейс и сама реализация сервиса:

public interface TokenService {
    String generateToken(String clientId);
}

@Service
public class DefaultTokenService implements TokenService {
    @Value("${auth.jwt.secret}")
    private String secretKey;

    @Override
    public String generateToken(String clientId) {
        Algorithm algorithm = Algorithm.HMAC256(secretKey);

        Instant now = Instant.now();
        Instant exp = now.plus(5, ChronoUnit.MINUTES);

        return JWT.create()
                .withIssuer("auth-service")
                .withAudience("bookstore")
                .withSubject(clientId)
                .withIssuedAt(Date.from(now))
                .withExpiresAt(Date.from(exp))
                .sign(algorithm);
    }
}

Здесь мы формируем JWT из набора claims (утверждений):

  • iss(Issuer) — издатель токена
  • aud(Audience) — для какого сервиса предназначается токен (в нашем случае для сервиса BookStore)
  • sub(Subject) — идентификатор клиента (clientId)
  • iat — текущее время формирования токена
  • exp — вычисленное время окончания действия токена (выдаем на 5 минут)

Секретный ключ для подписи будет браться из application.properties. Сгенерировать случайный ключ можно следующим образом:

byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
String secretKey = new BigInteger(1, bytes).toString(16);

Опишем объекты запросов/ответов.
Аутентификационные данные пользователя:

@Value
public class User {
    String clientId;
    String clientSecret;
}

Ответ в случае успешного получения токена:

@Value
public class TokenResponse {
    String token;
}

Ответ в случае ошибки:

@Value
public class ErrorResponse {
    String message;
}

Остается описать контроллер:

@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

    private final ClientService clientService;
    private final TokenService tokenService;

    @PostMapping
    public ResponseEntity<String> register(@RequestBody User user) {
        clientService.register(user.getClientId(), user.getClientSecret());
        return ResponseEntity.ok("Registered");
    }

    @PostMapping("/token")
    public TokenResponse getToken(@RequestBody User user) {
        clientService.checkCredentials(
            user.getClientId(), user.getClientSecret());
        return new TokenResponse(
            tokenService.generateToken(user.getClientId()));
    }

    @ExceptionHandler({RegistrationException.class, LoginException.class})
    public ResponseEntity<ErrorResponse> handleUserRegistrationException(RuntimeException ex) {
        return ResponseEntity
                .badRequest()
                .body(new ErrorResponse(ex.getMessage()));
    }
}

Укажем в файле application.properties порт, на котором будет запускаться приложение, значение секретного ключа и настройки коннекта к БД:

server.port=8081

spring.datasource.url=jdbc:postgresql://localhost:5432/demo
spring.datasource.username=admin
spring.datasource.password=password
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jackson.serialization.indent_output=true

auth.jwt.secret=30faa058f27f690c7e9a098d54ebcfb3d8725bcb85ee7907a2d84c69622229e2

Запустим приложение и попробуем сгенерировать токен. Для этого зарегистрируем пользователя (считаем, что этот эндпоинт не находится в открытом доступе и это может сделать администратор сервиса):

curl -X POST --location "http://localhost:8081/auth" \
    -H "Content-Type: application/json" \
    -d "{
          \"clientId\": \"client_id\",
          \"clientSecret\": \"secret\"
        }"
        
Registered

Используя эти credentials получим токен:

curl -X POST --location "http://localhost:8081/auth/token" \
    -H "Content-Type: application/json" \
    -d "{
          \"clientId\": \"client_id\",
          \"clientSecret\": \"secret\"
        }"
        
{
  "token" : "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJib29rc3RvcmUiLCJzdWIiOiJjbGllbnRfaWQiLCJpc3MiOiJhdXRoLXNlcnZpY2UiLCJleHAiOjE2MzcxODMxMTIsImlhdCI6MTYzNzE4MjgxMn0.Gp-ou8icurgSIVjbZisCyqPyFR6GTTymUTrCbF6aisI"
}

Если расшифровать значение токена (например на сайте jwt.io), то получим следующее содержимое:

{
  "typ": "JWT",
  "alg": "HS256"
}
{
  "aud": "bookstore",
  "sub": "client_id",
  "iss": "auth-service",
  "exp": 1637183112,
  "iat": 1637182812
}

Получив такой токен, принимающей стороне необходимо будет сверить подпись и удостовериться в том, что токен выпущен именно для неё (aud) и доверенным сервером (iss).

Авторизация

Нам осталось добавить авторизацию в написанное приложение Bookstore. Откроем проект из предыдущей части и добавим в зависимости библиотеку для работы с JWT (как было показано выше). Также добавим две новых настройки в application.properties:

auth.enabled=true
auth.jwt.secret=30faa058f27f690c7e9a098d54ebcfb3d8725bcb85ee7907a2d84c69622229e2

Одна из них вам уже знакома, а вторую будем использовать для включение/отключения авторизации.

Для проверки полученного токена опишем интерфейс TokenService и его реализацию:

public interface TokenService {
    boolean checkToken(String token);
}

@Service
@Slf4j
public class DefaultTokenService implements TokenService {
    @Value("${auth.jwt.secret}")
    private String secretKey;

    @Override
    public boolean checkToken(String token) {
        Algorithm algorithm = Algorithm.HMAC256(secretKey);
        JWTVerifier verifier = JWT.require(algorithm).build();

        try {
            DecodedJWT decodedJWT = verifier.verify(token);
            if (!decodedJWT.getIssuer().equals("auth-service")) {
                log.error("Issuer is incorrect");
                return false;
            }

            if (!decodedJWT.getAudience().contains("bookstore")) {
                log.error("Audience is incorrect");
                return false;
            }
        } catch (JWTVerificationException e) {
            log.error("Token is invalid: " + e.getMessage());
            return false;
        }

        return true;
    }
}

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

Закроем все эндпоинты и разрешим доступ только при наличии авторизационного токена. Значение авторизационного токена необходимо положить в заголовок следующим образом:

Authorization: Bearer <значение токена>

Осталось написать фильтр, который будет осуществлять чтение заголовка и принимать решение об авторизации запроса:

@Component
@RequiredArgsConstructor
public class AuthorizationFilter extends OncePerRequestFilter {

    private final TokenService tokenService;

    @Value("${auth.enabled}")
    private boolean enabled;

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                    HttpServletResponse response, 
                                    FilterChain filterChain) throws ServletException, IOException {

        if (!enabled)
            filterChain.doFilter(request, response);

        String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

        if (authHeader == null || authHeader.isBlank())
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        else if (!checkAuthorization(authHeader))
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        else
            filterChain.doFilter(request, response);
    }

    private boolean checkAuthorization(String auth) {
        if (!auth.startsWith("Bearer "))
            return false;

        String token = auth.substring(7);
        return tokenService.checkToken(token);
    }
}

Запускаем второе приложение и пытаемся сделать запрос:

curl -I -X GET --location "http://localhost:8080/books" \
    -H "Accept: application/json"
    
HTTP/1.1 401

Статус 401 указывает на необходимость авторизоваться. Получим свежий токен и повторим запрос, передав его в заголовке Authorization:

curl -X POST --location "http://localhost:8081/auth/token" \
    -H "Content-Type: application/json" \
    -d "{
          \"clientId\": \"client_id\",
          \"clientSecret\": \"secret\"
        }"
        
{
  "token" : "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJib29rc3RvcmUiLCJzdWIiOiJjbGllbnRfaWQiLCJpc3MiOiJhdXRoLXNlcnZpY2UiLCJleHAiOjE2MzcxODU0ODEsImlhdCI6MTYzNzE4NTE4MX0.eE8yZZ8tMEKorn3qVSwkmj5AFfa9RwkSerdJZKwo_XA"
}

curl -X GET --location "http://localhost:8080/books" \
    -H "Accept: application/json" \
    -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJib29rc3RvcmUiLCJzdWIiOiJjbGllbnRfaWQiLCJpc3MiOiJhdXRoLXNlcnZpY2UiLCJleHAiOjE2MzcxODU0ODEsImlhdCI6MTYzNzE4NTE4MX0.eE8yZZ8tMEKorn3qVSwkmj5AFfa9RwkSerdJZKwo_XA"

[ 
    {
      "id" : 1,
      "author" : "Joshua Bloch",
      "title" : "Effective Java",
      "price" : 54.99
    }, {
      "id" : 2,
      "author" : "Kathy Sierra",
      "title" : "Head First Java",
      "price" : 12.66
    }, {
      "id" : 3,
      "author" : "Benjamin J. Evans",
      "title" : "Java in a Nutshell: A Desktop Quick Reference",
      "price" : 28.14
    }
]

Время действия токена мы выбрали 5 минут, поэтому через этот промежуток времени сервис вернёт статус 403, что будет означать невалидность токена и необходимость получить новый:

curl -I -X GET --location "http://localhost:8080/books" \
    -H "Accept: application/json" \
    -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJib29rc3RvcmUiLCJzdWIiOiJjbGllbnRfaWQiLCJpc3MiOiJhdXRoLXNlcnZpY2UiLCJleHAiOjE2MzcxODU0ODEsImlhdCI6MTYzNzE4NTE4MX0.eE8yZZ8tMEKorn3qVSwkmj5AFfa9RwkSerdJZKwo_XA"
    
HTTP/1.1 403 

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

Код проекта доступен на GitHub.