Go together

If you want to go fast, go alone. If you want to go far, go together.

React, 스프링, AWS로 배우는 웹개발 101

인증 백엔드 통합

NowChan 2022. 3. 28. 13:53

애플리케이션을 배포하려면 어떤 형태로든 인증 메커니즘을 구현해야 한다. 이 장에서는 REST API 인증에 대해 직관적으로 알아볼 것이다.

 

  • 인증: 사용자가 누구인지?
  • 인가: 사용자 별로 할 수 있는 자원(resource)를 정의한다.

 

인가의 예시) 인증된 사용자(누구인지 식별된 사용자)가 데이터 A 또는 API(기능) A를 사용할 수 있는가? 만약 사용할 수 있다면 이는 인가된(authorized) 사용자이다.

 

인증과 인가를 구현하려면 적합한 아키텍처 디자인을 선택해야 한다. 스케일(서비스 사용자 수를 고려한 서비스 크기)에 따른 기존 인증 방식(Basic, 토큰 등의 인증 스탠다드)의 한계와 JSON Web Token을 이용해 해결하는 방법을 알아본다. 또한 스프링 시큐리티, Bearer 인증, JSON Web Token을 이용해 패스워드를 통한 인증을 직접 구현한다.

 

REST API 인증 기법 (REST Security)

 

 

Basic 인증

 

Todo 앱은 로그인 여부를 제외하면 유지해야할 상태가 없다. 상태가 거의 없는 웹 애플리케이션에서 사용할 수 있는 가장 간단한 인증 방법이 Basic 인증이다. 

 

Basic 인증은 모든 HTTP 요청 헤더에 ID, PW를 포함시켜 보내는 것이다. HTTP 요청 헤더의 Authorization: 부분에 'Basic <ID>:<Password>'처럼 ID, PW를 콜론으로 이어붙인 후 아래 처럼 Base64로 인코딩한 문자열을 보낸다.

 

[Basic Auth]

Authorization: Basic aGVsbG93b3JsZEBnbWFpbC5jb206MTIzNA==

 

이 요청을 수신한 서버가 문자열을 디코딩해서 인증 서버의 레코드와 비교한 후 요청을 수행하거나 거부한다.

 

 

HTTP + Basic 인증 방식의 단점은 크게 3가지가 있다.

 

  1. ID, PW를 노출한다.
  2. 사용자를 로그아웃시킬 수 없다.
  3. 인증 DB에 과부화가 걸릴 확률이 높다.

 

HTTP는 요청을 가로챌 수 있고(MITM, Man in the Middle Attack), Basic 인증 방식은 디코더만 있으면 문자열을 바로 해독할 수 있다. 따라서 Basic 인증 방식은 HTTPS와 사용해야 한다.

 

Basic 인증 방식은 모든 요청이 일종의 로그인 요청이기 때문에 로그아웃시킬 수 없다.

 

사용자의 계정 정보가 있는 저장 장소(Identity Management System)인 인증 서버의 경우 과부화가 걸릴 확률이 높다. Basic 인증 방식의 경우 서비스가 커질 수록 요청마다 인증 서버를 거쳐 인증을 해야하기 때문이다. 가령, 서비스 1과 2가 있을 때 서비스 1내에서 서비스 2에 HTTP 요청을 보내면, 서비스 2는 이 요청 또한 인증해야 한다. 만약 서비스가 10개 있고 서비스 1이 나머지 9개 서비스를 이용한다면 요청을 처리하려면 10번의 인증을 해야 한다. 1초에 10만 개의 요청을 처리하려면 인증 서버에 100만 번 인증해야 한다.

 

이러한 스케일 문제는 인증 DB가 단일 장애점(Single Point of Failure)이 되어서 인증 DB에 오류가 나면 전체 시스템이 가동 불가하게 된다.

 

 

토큰 기반 인증

 

Token은 사용자를 구별할 수 있는 문자열이다. 토큰은 최초 로그인 시 서버가 만들어 준다. 서버가 토큰을 만들어 클라이언트에 반환하면, 클라이언트는 이후 요청에 ID/PW 대신 Token을 계속 넘긴다.

 

요청 헤더에 Authorization: Bearer 을 명시한다.

 

[Bearer Token]

Authorization: Bearer Nn4d1MOVLZg79sfFACTIpCPKqWmpZMZQsbNrXdJJNWkRv50_l7bPLQPwhMobT4vBOG6Q3JYjhDrKFlBSaUxZOg

 

 

백엔드 서버가 토큰을 만들면 인증 서버에도 저장한다. 그리고 요청을 받을 때마다 헤더의 토큰과 인증 서버의 토큰을 비교해 클라이언트를 인증한다. 

 

토큰 기반 인증의 장점

 

  1. ID/PW를 처음 로그인에서만 노출해서 안전하다.
  2. 서버가 토큰을 마음대로 생성할 수 있으므로 사용자의 인가 정보(Admin, User 등), 유효 시간을 정해 관리할 수 있다.
  3. 디바이스마다 다른 토큰을 생성하고 임의로 로그아웃을 할 수도 있다.

 

토큰 이용만으로는 Basic 인증의 스케일 문제를 해결할 수 없다.

 

 

JSON 웹 토큰

 

서버에서 전자 서명된 토큰을 이용하면 인증에 따른 스케일 문제를 해결할 수 있다. JWT는 오픈 스탠더드(표준)이다. JWT 토큰은 {header}.{payload}.{signature}로 구성되어 있다.

 

[JWT 예시]

Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0ZXJAdGBzdC5jb20iLCJ
pYXQiOjE1OTU#MzM2NTcsImV4cCI6MTU5NjU5NzY1N30.Nn4d1MOVLZg79sfFACTIpCPKqWmpZMZQsb
NrXdJJNWkRv50_l7bPLQPwhMobT4vBOG6Q3JYjhDrKFlBSaUxZOg

 

위 JWT 토큰을 디코딩하면 아래와 같다.

 

[디코딩한 JWT 예시]

 

{  // header
   "typ": "JWT",
   "alg": "HS512"
},
{  // payload
   "sub": "40288093784915d201784916a40c0001",
   "iss": "demo app",
   "iat": 1595733657,
   "exp": 1596597657
}.
Nn4d1MOVLZg79sfFACTIpCPKqWmpZMZQsbNrXdJJNWkRv50_l7bPLQPwhMobT4vBOG6Q3JYjhDrKFlBSaUxZOg
// signature

 

각 파트의 필드는 다음과 같은 의미를 가진다.

 

Header

  • type: 토큰의 타입
  • alg: 서명을 발행하는 데 사용된 해시 알고리듬의 종류

 

Payload

  • sub: 토큰의 주인, 우리 애플리케이션은 사용자의 이메일로 토큰의 주인을 판별한다.
  • iss: 토큰을 발행한 주체, 페이스북이 발행했다면 facebook이 된다.
  • iat: 토큰이 발행된 날짜와 시간
  • exp: 토큰이 만료되는 시간

 

Signature

  • 토큰을 발행한 주체(Issuer)가 생성한 서명, 토큰의 유효성 검사에 사용됨

 

JWT도 토큰 기반 인증이다. 다른 점은 헤더와 페이로드를 생성한 후 전자 서명을 한다는 점이다.

 

전자 서명은 {헤더}.{페이로드}와 시크릿키를 이용해 해시 함수로 암호화한 결과값이다. 시크릿키는 나만 알고 있는 문자열같은 것이다. 최초 로그인 시 서버는 사용자의 ID, PW를 인증 서버의 데이터와 비교해 인증한다. 인증된 사용자일 경우, 사용자 정보를 {헤더}.{페이로드}로 작성하고 서버의 시크릿키를 통해 {헤더}.{페이로드}를 전자 서명한다. 결과인 {헤더}.{페이로드}.{서명}을 Base64로 인코딩한 후 반환한다.

 

이후 이 토큰으로 리소스 접근을 요청하면 서버는 토큰을 Base64로 디코딩 한 후 JSON을 {헤더}.{페이로드}와 {서명}으로 나누고, {헤더}.{페이로드}를 서버가 가진 시크릿 키로 전자 서명을 한 결과를 {서명}과 비교한다. 이를 유효성을 검사했다고 한다. 만약 결과와 {서명}이 일치하면 요청한 리소스를 반환한다.

 

전자 서명을 비교하는 유효성 검사는 인증 서버에 토큰의 유효성을 물어볼 필요가 없다. 이는 인증 서버에 부하를 일으키지 않는다는 뜻이고, 더 이상 인증 서버가 단일 장애점이 아니라는 뜻이다.

 

누가 토큰을 훔쳐가면 당연히 해당 계정의 리소스에 접근할 수 있으므로 반드시 HTTPS를 통해 통신해야 한다.

 

 

User 레이어 구현

 

사용자를 관리하려면 사용자에 관련된 모델, 서비스, 리포지터리, 컨트롤러가 필요하다. 사용자 기능을 구현하는 독립적으로 사용 가능한 클래스에 스프링 시큐리티를 접목시켜 사용할 수 있다.

 

 

퍼시스턴스 레이어

 

[UserEntity.java]

package com.example.demo.model;

import lombok.AllArgsConstructor;   // 매개변수가 모두 포함된 생성자
import lombok.Builder;              // Builder 디자인 패턴 
import lombok.Data;                 // getter/setter 생성
import lombok.NoArgsConstructor;    // 매개변수 없는 생성자
import org.hibernate.annotations.GenericGenerator;  // ID를 자동으로 만들 generator 생성

import javax.persistence.Column;
import javax.persistence.Entity;    // java 클래스를 Entity로 설정
import javax.persistence.GeneratedValue;    // 자동으로 Id 생성
import javax.persistence.Id;        // 기본키가 될 필드
import javax.persistence.Table;     // DB 테이블에 매핑
import javax.persistence.UniqueConstraint;  // Column이 유일한 값을 갖도록 설정

@Data
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(uniqueConstraints = {@UniqueConstraint(columnNames = "email")})
public class UserEntity {
    @Id
    @GeneratedValue(generator = "system-uuid")
    @GenericGenerator(name="system-uuid", strategy = "uuid")
    private String id;      // 사용자에게 고유하게 부여되는 id

    @Column(nullable = false)
    private String username;// 사용자의 이름

    @Column(nullable = false)
    private String email;   // 사용자의 email, 아이디와 같은 기능을 함

    @Column(nullable = false)
    private String password;// 사용자의 pw

}

 

 

[UserRepository.java]

package com.example.demo.persistence;

import com.example.demo.model.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface UserRepository extends JpaRepository<UserEntity, String>{
                    // JpaRepository:인터페이스, <Table에 매핑될 Entity class, Entity의 기본키 타입> 
    UserEntity findByEmail(String email);
    Boolean existsByEmail(String email);
    UserEntity findByEmailAndPassword(String email, String password);
}

 

 

서비스 레이어

 

[UserService.java]

package com.example.demo.service;

import com.example.demo.persistence.UserRepository;
import com.example.demo.model.UserEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class UserService {

    @Autowired  // 빈에 등록된 클래스를 찾아 생성
    private UserRepository userRepository;

    public UserEntity create(final UserEntity userEntity){
        
        if(userEntity == null || userEntity.getEmail() == null){
            throw new RuntimeException("Invalid arguments");
        }
        final String email = userEntity.getEmail();

        if(userRepository.existsByEmail(email)){
            log.warn("Email already exists {}", email);
            throw new RuntimeException("Email already exists");
        }

        return userRepository.save(userEntity);

    }

    public UserEntity getByCredentials(final String email, final String password){
        return userRepository.findByEmailAndPassword(email, password);
    }

}

 

 

컨트롤러 레이어

 

[UserDTO.java]

package com.example.demo.dto;

import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import lombok.Data;
import lombok.Builder;

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class UserDTO {
    private String token;
    private String email;
    private String username;
    private String password;
    private String id;
}

 

[UserController.java]

package com.example.demo.controller;

import com.example.demo.dto.ResponseDTO;
import com.example.demo.dto.UserDTO;
import com.example.demo.model.UserEntity;
import com.example.demo.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; // @Component stereotype

@Slf4j
@RestController
@RequestMapping("/auth")
public class UserController {
    
    @Autowired
    private UserService userService;

    @PostMapping("/signup") // RequestBody로 보내는 JSON -> userDTO 객체로 변환
    public ResponseEntity<?> registerUser(@RequestBody UserDTO userDTO){
        try{
            // 요청을 이용해 저장할 사용자 만들기
            UserEntity user = UserEntity.builder()
                                .email(userDTO.getEmail())
                                .username(userDTO.getUsername())
                                .password(userDTO.getPassword())
                                .build();

            // 서비스를 이용해 리포지터리에 사용자 지정
            UserEntity registeredUser = userService.create(user);
            UserDTO responseUserDTO = UserDTO.builder()
                                .email(registeredUser.getEmail())
                                .id(registeredUser.getId())
                                .username(registeredUser.getUsername())
                                .build();

            // 사용자 정보는 항상 1개 이므로 ResponseDTO 대신 UserDTO 리턴
            return ResponseEntity.ok().body(responseUserDTO);

        }catch(Exception e){

            ResponseDTO responseDTO = ResponseDTO.builder().error(e.getMessage()).build();

            return ResponseEntity
                    .badRequest()
                    .body(responseDTO);
        }
    }

    @PostMapping("/signin")
    public ResponseEntity<?> authenticate(@RequestBody UserDTO userDTO){
        
        UserEntity user = userService.getByCredentials(
            userDTO.getEmail(),
            userDTO.getPassword()
        );

        if(user != null){
            final UserDTO responseUserDTO = UserDTO.builder()
                .email(user.getUsername())
                .id(user.getId())
                .build();
            
            return ResponseEntity.ok().body(responseUserDTO);
        }else{
            ResponseDTO responseDTO = ResponseDTO.builder()
                                        .error("Login failed.")
                                        .build();
            return ResponseEntity
                    .badRequest()
                    .body(responseDTO);
        }

    }
}

 

테스팅

curl -d '{"email":"hello@world.com","username":"user1","password":"12345"}'\
     -H 'Content-Type:application/json'\
     -X POST localhost:8080/auth/signup


// 결과
{"token":null,
 "email":"hello@world.com",
 "username":"user1",
 "password":null,
 "id":"ff8080817fde3f27017fde4143f70000"}

 

curl -d '{"email":"hello@world.com","password":"12345"}'\
     -H 'Content-Type:application/json'\
     -X POST localhost:8080/auth/signin


// 결과
{"token":null,
 "email":"user1",
 "username":null,
 "password":null,
 "id":"ff8080817fde3f27017fde4143f70000"
}

 

회원가입과 로그인이 잘 동작함을 알 수 있다. 현재 구현에서 부족한 점은 3가지이다.

 

  1. 로그인만 되고, 로그인 상태가 유지되지 않는다. 다른 API는 사용자가 로그인했는지 아닌지 모른다.
  2. 다른 API는 사용자의 로그인 여부 자체를 확인하지 않는다. 즉, 모든 사용자가 같은 Todo 리스트를 보고 있다.
  3. 패스워드를 암호화하지 않는다. 보안 규정에 위배 된다.

 

 

스프링 시큐리티 통합

 

사용자 로그인 여부 저장을 스프링 시큐리티와 JWT 토큰을 이용해 해결할 예정이다.

 

또한, 모든 API 요청에 토큰 또는 Id & pw를 보내야 하는데, 토큰의 유효성을 판별하는 메서드를 작성해 모든 API에 추가하는 것은 여러 번 같은 코드를 반복 작성해서 비효율적이다. 이러한 문제를 스프링 시큐리티를 통해 모든 API가 수행되기 전에 유효성 검사 코드를 실행하도록 구현할 수 있다.

 

 

JWT 생성 및 반환 구현

 

우리는 사용자가 {헤더}.{페이로드}를 요청으로 보내면 {헤더}.{페이로드}.{서명}인 토큰을 반환하는 코드를 작성해야 한다. 먼저, JWT 관련 라이브러리인 jjwt를 build.gradle의 dependencies에 추가한다.

 

// https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt
implementation group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'

 

security 패키지에서 인증과 인가를 위한 모든 클래스를 관리할 것이다. 먼저 사용자의 정보를 받아 JWT를 생성하는 TokenProvider 클래스를 만들겠다.

 

[TokenProvider.java]

package com.example.demo.security;

import com.example.demo.model.UserEntity;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;

@Slf4j
@Service
public class TokenProvider{
    private static final String SECRET_KEY = "NMA8JPctFuna59f5";

    public String create(UserEntity userEntity){
        // 기한은 지금부터 1일로 설정
        Date expiryDate = Date.from(
            Instant.now()
            .plus(1, ChronoUnit.DAYS)
        );

        // JWT Token 생성
        return Jwts.builder()
            // header에 들어갈 내용 및 서명을 하기 위한 SECRET_KEY
            .signWith(SignatureAlgorithm.HS512, SECRET_KEY)
            // payload에 들어갈 내용
            .setSubject(userEntity.getId()) // sub
            .setIssuer("demo app")  // iss
            .setIssuedAt(new Date()) // iat
            .setExpiration(expiryDate)  // exp
            .compact();
    }

    public String validateAndGetUserId(String token){
        // parseClaimsJws 메서드가 Base64로 디코딩 및 파싱
        // 헤더와 페이로드를 setSigningKey로 넘어온 시크릿을 이용해 서명한 후 token의 서명과 비교
        // 위조되지 않았다면 페이로드(Claims) 리턴, 위조라면 예외를 날림
        // 그중 우리는 userID가 필요하므로 getBody를 부른다.
        Claims claims = Jwts.parser()
            .setSigningKey(SECRET_KEY)
            .parseClaimsJws(token)
            .getBody();

        return claims.getSubject();
    }

}

 

이제 로그인 부분에서 TokenProvider를 이용해 토큰을 생성한 후 UserDTO에 이를 반환해야 한다.

 

[UserController.java]

import com.example.demo.security.TokenProvider;
..

 // class UserController 중
    @Autowired
    private TokenProvider tokenProvider;
    
    ... 
    
    @PostMapping("/signin")
    public ResponseEntity<?> authenticate(@RequestBody UserDTO userDTO){
        
       ...

        if(user != null){
            final String token = tokenProvider.create(user);
            final UserDTO responseUserDTO = UserDTO.builder()
                .email(user.getUsername())
                .id(user.getId())
                .token(token)
                .build();
            
            return ResponseEntity.ok().body(responseUserDTO);
        }

 

테스팅

 

curl -d '{"email":"hello@world.com","username":"user1","password":"12345"}'\
     -H 'Content-Type:application/json'\
     -X POST localhost:8080/auth/signup

// 결과
{"token":null,
 "email":"hello@world.com",
 "username":"user1",
 "password":null,
 "id":"ff8080817fe3660d017fe3666e3c0000"}

 

curl -d '{"email":"hello@world.com","password":"12345"}'\
     -H 'Content-Type:application/json'\
     -X POST localhost:8080/auth/signin

// 결과
{"token":"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJmZjgwODA4
 MTdmZTM2NjBkMDE3ZmUzNjY2ZTNjMDAwMCIsImlzcyI6ImRlbW8gYXB
 wIiwiaWF0IjoxNjQ4Nzg3NjQwLCJleHAiOjE2NDg4NzQwNDB9.7iYpTsuxHOLrDgl_3rNn
 tnoVMwFmh2IETTE5G6b38I9sWbYHuO59QRuEmMZaW_jzW2DF8aa8Zb47LNfVtvwwmA",
 "email":"user1",
 "username":null,
 "password":null,
 "id":"ff8080817fe3660d017fe3666e3c0000"}

 

token 필드가 잘 리턴됨을 알 수 있다. token을 Base64로 디코딩한 결과는 아래와 같은 형태이다.

 

{
 "alg": "HS512"
}
.
{
 "sub": "ff8080817fe3660d017fe3666e3c0000",
 "iss": "demo app",
 "iat": 1616311066,
 "exp": 1616397466
}
.
[이상한 문자열]

 

토큰을 반환하는 부분을 구현했으니 이제 이 토큰을 이용해 API마다 인증하는 부분을 구현하겠다. API가 실행될 때마다 사용자 인증을 구현해야 하는데, 이 부분을 스프링 시큐리티의 도움을 받아 구현한다.

스프링 시큐리티란 간단히 말하면, 서블릿 필터의 집합이다. 서블릿 필터란 서블릿 실행 전에 실행되는 클래스들이다. 서블릿의 예를 들면, 스프링이 구현하는 서블릿은 디스패처 서블릿이다.

개발자가 해야할 일은

  1. 서블릿 필터를 구현한다.
  2. 서블릿 필터를 서블릿 컨테이너가 실행하도록 설정한다.


서블릿 필터는 로직에 따라 원하지 않는 HTTP 요청을 걸러낼 수 있다. 모든 서블릿 필터를 거쳐 살아남은 요청은 서블릿으로 넘어와 컨트롤러에서 실행된다. 서블릿 필터는 HttpFilter 또는 Filter를 상속하는 클래스다. 이 클래스를 상속해 doFilter라는 메서드를 원하는 대로 오버라이딩해준다.

[예제]

package com.example.demo.security;

import org.springframework.util.StringUtils;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

// 예제
public class ExampleServletFilter extends HttpFilter{
    private TokenProvider tokenProvider;

    @Override
    protected void doFilter(HttpServletRequest request,
                            HttpServletResponse response,
                            FilterChain filterChain)
                throws IOException, ServletException{
        try{
            final String token = parseBearerToken(request);

            if(token != null && !token.equalsIgnoreCase("null")){
                // userId 가져오기. 위조된 경우 예외 처리된다.
                String userId = tokenProvider.validateAndGetUserId(token);

                // 다음 ServletFilter 실행
                filterChain.doFilter(request, response);
            }
        } catch (Exception e){
            // 예외 발생 시 response를 403 Forbidden으로 설정
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        }     
    }

    private String parseBearerToken(HttpServletRequest request){
        // Http 요청의 헤더를 파싱해 Bearer 토큰을 리턴한다.
        String bearerToken = request.getHeader("Authorization");

        if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")){
            return bearerToken.substring(7);
        }
        
        return null;
    }
}



인증이 완료되면, 다음 서블릿 필터를 실행하고 아니면 HttpSevletResponse의 status를 403 Forbidden으로 바꾼다. 다음으로 구현한 필터를 서블릿 컨테이너(톰캣)가 사용하도록 설정해야 한다.

 

스프링 부트를 사용하지 않는 웹 서비스는 xml에 설정된 필터를 실행시켜준다.

 

[서블릿 필터 설정 예제]

<filter>
   <filter-name>ExampleServletFilter</filter-name>
   <filter-class>com.example.demo.security.ExampleServletFilter</filter-class>
   /*다른 매개변수들*/
</filter>
<filter-mapping>
   <filter-name>ExampleServletFitler</filter-name>
   <url-pattern>/todo</url-pattern>
</filter-mapping>


서블릿 필터는 여러 개일 수 있는데, 이 서블릿 필터를 FilterChain을 이용해 연쇄적•순서대로 실행할 수 있다. FilterChain 안에 다음으로 부를 Filter를 갖고 있고, doFilter를 통해 실행한다.

 

// 다음 ServletFilter 실행
filterChain.doFilter(request, response);



스프링 시큐리티가 FilterChainProxy 필터를 서블릿 필터들 사이에 끼워 넣어 준다. FilterChainProxy 클래스 안에서 내부적으로 필터를 실행 시키는데 이 필터가 스프링이 관리하는 스프링 빈(bean) 필터다.

스프링 빈 필터라고 크게 다르지 않다. 상속하는 필터가 HttpFilter → OncePerRequestFilter이고, web.xml 대신 WebSecurityConfigurerAdapter 클래스를 상속해 사용할 필터를 알려준다.

 


JWT를 이용한 인증 구현

스프링 시큐리티 디펜던시를 build.gradle의 dependencies에 추가한다.

 

implementation 'org.springframework.boot:spring-boot-starter-security:'


OncePerRequestFilter를 상속하는 필터는 요청마다 한 번만 실행되므로 로그인 인증에 적합하다. 유효시간 검사는 스스로 작성한다.

 

package com.example.demo.security;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Slf4j
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter{
    
    @Autowired
    private TokenProvider tokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        try {
            // 요청에서 토큰 가져오기
            String token = parseBearerToken(request);
            log.info("Filter is running..");
            
            // 토큰 검사하기. JWT이므로 인가 서버에 요청하지 않고도 검증 가능
            if(token != null && !token.equalsIgnoreCase("null")){
                // userId 가져오기. 위조된 경우 예외 처리된다.
                String userId = tokenProvider.validateAndGetUserId(token);
                log.info("AUthenticated user ID : " + userId);
                // 인증 완료. SecurityContextHolder에 등록해야 인증된 사용자라고 생각한다.
                AbstractAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                  userId // 인증된 사용자 정보, 문자열이 아니어도 아무거나 넣을 수 있다.  
                , null // 보통 UserDetails 오브젝트를 넣는데 우리는 안넣었다.
                , AuthorityUtils.NO_AUTHORITIES
                );
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
                securityContext.setAuthentication(authentication);
                SecurityContextHolder.setContext(securityContext);
            }
        } catch (Exception e){
            logger.error("Could not set user authentication in security context", e);
        }

        filterChain.doFilter(request, response);
    }

    private String parseBearerToken(HttpServletRequest request){
        // Http 요청의 헤더를 파싱해 Bearer 토큰을 리턴한다.
        String bearerToken = request.getHeader("Authorization");

        if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")){
            return bearerToken.substring(7);
        }

        return null;
    }

}

 

  1. parseBearerToken() 메서드에서 요청의 헤더에 Bearer 토큰을 가져온다.
  2. TokenProvider를 이용해 토큰을 인증하고, UsernamePasswordAuthenticationToken을 작성한다. 이 오브젝트에 사용자의 인증 정보를 저장하고, SecurityContext에 인증된 사용자를 등록한다. 등록하는 이유는 요청을 처리하는 과정에서 사용자가 인증됐는지 여부와 인증된 사용자가 누구인지를 알아야할 때가 있기 때문이다.

 

  • SecurityContext 생성 : SecurityContextHolder.createEmptyContext()로 가능
  • SecurityContext 등록 : SecurityContext에 authentication(인증 정보)를 넣고 SecurityContextHolder에 컨텍스트로 등록한다.

 

SecurityContextHolder는 ThreadLocal에 저장된다. ThreadLocal은 더보기를 참조하라. SecurityContextHolder가 ThreadLocal에 저장되므로 Thread마다 하나의 컨텍스트를 관리한다.

더보기

ThreadLocal

 

객체는 메모리의 Heap(모든 스레드에서 공유, class의 멤버 변수), Stack(한 스레드에서만 사용) 영역에 배치할 수 있습니다. 변수를 Stack 영역에 배치하려면, 파라미터로 선언한다. 메서드 로직에 따라 값을 가공한 후 리턴한 값만 외부에서 사용할 수 있다.

 

Threadlocal을 사용하면 한 스레드 안의 변수를 파라미터, 리턴값으로 전달하지 않고도 외부 스레드와 공유할 수 있다.  Threadlocal은 내부 정보를 key로 하는 Map 구조(key-value 쌍)로 이루어져 있고, set · get 메서드를 통해 값을 전달한다.

 

Threadlocal을 통해 사용자는 한 쓰레드에서 실행하는 코드 간에 동일한 객체를 공유할 수 있다.

 

ThreadPool 환경에서 사용할 경우 ThreadLocal 변수는 사용 후 제거해주어야 이상한 값이 참조되지 않는다.

 

출처: https://sabarada.tistory.com/163

https://javacan.tistory.com/entry/ThreadLocalUsage

 

우리가 만든 서블릿 필터를 사용하도록 알려주는 설정 작업을 하겠다. config 패키지 아래 WebSecurityConfig 클래스를 만들겠다. 앞서 설명했듯 이 클래스는 WebSecurityConfigurerAdapter를 상속해서 필터를 서블릿 컨테이너의 할일 목록에 등록한다.

 

[WebSecurityConfig.java]

package com.example.demo.config;

import com.example.demo.security.JwtAuthenticationFilter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.web.filter.CorsFilter;
import lombok.extern.slf4j.Slf4j;

@EnableWebSecurity
@Slf4j
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        // http 시큐리티 빌더
        http.cors() // WebMvcConfig에서 이미 설정했으므로 기본 cors 설정
            .and()
            .csrf() // 현재 csrf 사용 X
                .disable()
            .httpBasic()    // token 사용
                .disable()
            .sessionManagement()    // session 기반 x
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()    // '/'와 '/auth/**'(로그인)는 인증 X
                .antMatchers("/", "/auth/**").permitAll()
            .anyRequest()   // 위 경로 외 다른 경로는 모두 인증
                .authenticated();

        // filter 등록, 매 요청마다 CorsFilter(그냥 적당한 필터) 실행 후 jwt..Filter 실행
        http.addFilterAfter(
            jwtAuthenticationFilter,
            CorsFilter.class
        );
    }

}

 

HttpSecurity는 시큐리티 설정을 위한 오브젝트로, 빌더를 이용해 cors, csrf, httpBasic, session, authorizeRequests 등을 설정할 수 있다. HttpSecurity 오브젝트를 web.xml 대신 사용할 수 있다.

 

마지막 부분에 addFilterAfter 메서드는 CorsFilter 이후에 jwt..Filter를 실행하도록 필터를 추가한 것이다. CorsFilter는 그냥 적당히 설정한 필터라서 다른 필터로 설정해도 된다.

 

@EnableWebSecurity 어노테이션을 아래와 같이 바꾸고 로그를 출력하면 CorsFilter 다음에 JwtAuthenticaitonFilter가 추가되어있음을 알 수 있다.

@EnableWebSecurity(debug = true)


// 결과
Security filter chain: [
  WebAsyncManagerIntegrationFilter
  SecurityContextPersistenceFilter
  HeaderWriterFilter
  CorsFilter
  JwtAuthenticationFilter
  LogoutFilter
  RequestCacheAwareFilter
  SecurityContextHolderAwareRequestFilter
  AnonymousAuthenticationFilter
  SessionManagementFilter
  ExceptionTranslationFilter
  FilterSecurityInterceptor
]

 

테스팅

 

회원 가입 후 로그인

curl -d '{"email":"hello@world.com","password":"12345"}'\
     -H 'Content-Type:application/json'\
     -X POST localhost:8080/auth/signin
     
// 결과
{"token":"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJmZjgwODA4MTdmZjI1NGMxMDE3ZmYyN
  TllMTBhMDAwMCIsImlzcyI6ImRlbW8gYXBwIiwiaWF0IjoxNjQ5MDM4NDkwLCJleHAiOjE
  2NDkxMjQ4OTB9.vGXm62YnfkcRwQMak3_NUXIx0Sqf4by095MWnAUk6HcTaK0dxq7_3Qcp
  sS-9nGqcCGkwPKs8W97OTM--w8WD9Q",
  "email":"user1",
  "username":null,
  "password":null,
  "id":"ff8080817ff254c1017ff259e10a0000"}

 

받은 JWT 토큰으로 GET 요청

curl -d '{"email":"hello@world.com","password":"12345"}'\
     -H 'Content-Type:application/json'\
     -H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIi
      OiJmZjgwODA4MTdmZjI1NGMxMDE3ZmYyNTllMTBhMDAwMCIsImlzcyI6
      ImRlbW8gYXBwIiwiaWF0IjoxNjQ5MDM4NDkwLCJleHAiOjE2NDkxMjQ4
      OTB9.vGXm62YnfkcRwQMak3_NUXIx0Sqf4by095MWnAUk6HcTaK0dxq7
      _3QcpsS-9nGqcCGkwPKs8W97OTM--w8WD9Q'\
     -X GET localhost:8080/todo

// 결과
{"error":null,"data":[]}

// JwtAuthenticaitonFilter 로그
2022-04-04 02:17:35.566  INFO 16289 --- [nio-8080-exec-4]
  c.e.d.security.JwtAuthenticationFilter   : 
  AUthenticated user ID : ff8080817ff254c1017ff259e10a0000

 

위조된 토큰으로 GET 요청

curl -d '{"email":"hello@world.com","password":"12345"}'\
     -H 'Content-Type:application/json'\
     -H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIi
       OiJmZjgwODA4MTdmZjI1NGMxMDE3ZmYyNTllMTBhMDAwMCIsImlzcyI
       6ImRlbW8gYXBwIiwiaWF0IjoxNjQ5MDM4NDkwLCJleHAiOjE2NDkxMjQ
       4OTB9.vGXm62YnfkcRwQMak3_NUXIx0Sqf4by095MWnAUk6HcTaK0dxq
       7_3QcpsS-9nGqcCGkwPKs8W97OTM--w8WD9Qdasdad'\
     -X GET localhost:8080/todo
     
// 결과 X

// 위조된 토큰 로그
io.jsonwebtoken.SignatureException: JWT signature does not match locally 
computed signature. JWT validity cannot be asserted and should not be trusted.

 

JWT를 신뢰할 수 없어 예외처리 됨을 확인할 수 있다.

 

앞서 우리가 만든 Todo 컨트롤러는 디폴트 사용자로 'temporary-user'를 사용하고 있다. 이제 JWT 토큰에서 가져온 사용자 아이디를 사용하도록 코드를 수정해야한다.

 

[TodoController.java]

// class TodoController 중

   public ResponseEntity<?> createTodo(
                 @AuthenticationPrincipal String userId,    
                 @RequestBody TodoDTO dto){
                 ...
            // (3) AuthenticalPrincipal로 넘어온 userId 사용
            entity.setUserId(userId);
   }

    public ResponseEntity<?> retrieveTodoList(
                @AuthenticationPrincipal String userId){
                
        // (1) 서비스 메서드의 retrieve() 메서드를 사용해 Todo 리스트를 가져온다.
        List<TodoEntity> entities = service.retrieve(userId);
                  ...
    }
    
    public ResponseEntity<?> updateTodo(
            @AuthenticationPrincipal String userId,
            @RequestBody TodoDTO dto){
                 ...
        // (2) userId로 id를 초기화
        entity.setUserId(userId);
                 ...
    }

    public ResponseEntity<?> deleteDTO(
            @AuthenticationPrincipal String userId,
            @RequestBody TodoDTO dto){
                ...
        // (2) userId로 id를 초기화
        entity.setUserId(userId);
                ...
    }

 

@AuthenticationPrincipal을 이용해 스프링은 userId를 찾는다. Jwt..Filter 클래스에서 UsernamePasswordAuthenticationToken 생성자의 첫 번째 매개변수로 넣은 것이 AuthenticationPrincipal이다. 

 

AbstractAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
       userId // <- AuthenticationPrincipal (또는 principal)
       , null // 보통 UserDetails 오브젝트를 넣는데 우리는 안넣었다.
       , AuthorityUtils.NO_AUTHORITIES
);

 

우리는 Username..Token 오브젝트를 SecurityContext에 등록했다. 스프링은 컨트롤러 메서드를 실행할 때, @AuthenticationPrincipal 어노테이션이 SecurityContextHolder에서 UsernamePasswordAuthenticationToken(SecurityContext::Authentication) 오브젝트를 가져온다. 이 오브젝트에서 AutheticationPrincipal을 가져와 컨트롤러 메서드에 넘겨준다.

 

Username..Token에서 AuthenticationPrincipal에 지정한 오브젝트 타입(여기선 String)에 따라 컨트롤러 메서드의 매개변수의 type(여기선 String)이 지정된다.

 

 

테스팅

 

  • 사용자 1: hello@world.com
  • 사용자 2: hello2@world.com

 

사용자 1로 로그인한 후 Todo Item 1개를 등록합니다.

curl -d '{"title":"새포스트1"}'\
     -H "Content-Type: application/json"\
     -H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJmZjgw
         ODA4MTdmZjgxMjZkMDE3ZmY4MTQ4MTc5MDAwMCIsImlzcyI6ImRlbW8gYXBwIiwiaW
         F0IjoxNjQ5MTM0OTc1LCJleHAiOjE2NDkyMjEzNzV9.l_WzXUyRGbdiGWz__MLFJvh
         MxEl-GqZU4rKYJmY-giFXVon0nJA2S8iPSdL4e5sk29rhx_fq5IjHHknW-rIBMg'\
     -X POST localhost:8080/todo

// 결과
{"error":null,"data":[{"id":"ff8080817ff8126d017ff81b6d250002",
 "title":"새포스트1","done":false}]}

 

사용자 2로 로그인한 후 Todo Item 1개를 등록합니다.

curl -d '{"title":"공부하기2"}'\
     -H "Content-Type: application/json"\
     -H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzd
        WIiOiJmZjgwODA4MTdmZjgxMjZkMDE3ZmY4MTRmMDI4MDAwMS
        IsImlzcyI6ImRlbW8gYXBwIiwiaWF0IjoxNjQ5MTM1MDM4LCJ
        leHAiOjE2NDkyMjE0Mzh9.DjY90ybayNyFoLPzM_bJFcSytu_
        3ed_ImhTyallJJvm8wCVL3QkjSGGVqWyYSlwF0NHlJCBd7M6vdQFjC3Rs0g'\
     -X POST localhost:8080/todo
     
// 결과
{"error":null,"data":[{"id":"ff8080817ff8126d017ff81dce450003",
 "title":"공부하기2","done":false}]}

 

사용자 2로 로그인한 상태에서 GET 메서드로 '/todo'에 들어가면 사용자 2로 등록한 Todo Item만 리턴됩니다.

curl -H 'Content-Type:application/json'\
     -H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJ
        zdWIiOiJmZjgwODA4MTdmZjgxMjZkMDE3ZmY4MTRmMDI4MD
        AwMSIsImlzcyI6ImRlbW8gYXBwIiwiaWF0IjoxNjQ5MTM1M
        DM4LCJleHAiOjE2NDkyMjE0Mzh9.DjY90ybayNyFoLPzM_b
        JFcSytu_3ed_ImhTyallJJvm8wCVL3QkjSGGVqWyYSlwF0NHlJCBd7M6vdQFjC3Rs0g'\
     -X GET localhost:8080/todo
     
//결과
{"error":null,"data":[{"id":"ff8080817ff8126d017ff81dce450003",
 "title":"공부하기2","done":false}]}

 

 

패스워드 암호화

 

마지막으로 스프링 시큐리티가 제공하는 BCryptPasswordEncoder를 이용해 패스워드를 암호화한다. BCryptPasswordEncoder는 같은 값을 인코딩해도 다른 값이 나온다. 이는 패스워드에 랜덤한 의미없는 값을 붙여 결과를 생성하기 때문이다. 이런 의미 없는 값을 Salt라고 한다. Salt를 붙여 인코딩하는 것을 Salting이라고 한다. 따라서 사용자가 요청으로 보낸 패스워드를 인코딩해도 인증 DB에 저장된 패스워드와 다를 확률이 높다. 

 

대신 BCryptPasswordEncoder의 matches() 메서드를 이용하면, Salt를 고려해 두 값을 비교해준다. 패스워드 암호화를 위해 UserService와 UserController를 수정하겠다.

 

[UserService.java]

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

// class UserService 중

    public UserEntity getByCredentials(final String email, final String password,
                                       final PasswordEncoder encoder){

        final UserEntity originalUser = userRepository.findByEmail(email);

        // matches 메서드를 이용해 패스워드가 같은지 확인
        if(originalUser != null &&
           encoder.matches(password, originalUser.getPassword())){
               return originalUser;
           }
        
        return null;
    }

 

[UserController.java]

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

// class UserController 중

    @Autowired
    private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
    
    public ResponseEntity<?> authenticate(@RequestBody UserDTO userDTO){
        
        UserEntity user = userService.getByCredentials(
            userDTO.getEmail(),
            userDTO.getPassword(),
            passwordEncoder
        );
        
        ...
        
    }

 

 

정리

API 서비스 레벨에서 인증을 구현했다. 그리고 기본적인 인증과 인가 방법을 살펴봤다. 그리고 실제로 사용자 관리를 위한 User 레이어를 구현했다. 

 

OncePerRequestFilter를 상속해 Jwt..Filter를 작성했고, WebSecurityConfigurerAdapter를 상속해 인증해야할 경로와 그렇지 않은 경로를 설정했다.

 

인증 프론트엔드 통합장에서는 프론트엔드에서 인증, 인가 로직을 구현하고 서비스와 통합한다. 주로 구현할 부분은 아래와 같다.

 

  • 백엔드로부터 받은 토큰을 어디에 저장해 둘 것인가?
  • 인증되지 않은 사용자가 인증이 필요한 경로로 접근한 경우 어떻게 대처할 것인가?

 


궁금한 점

  • Instant.now().plus(1, ChronoUnit.DAYS) 찾아보기
  • Jwts.compact() 찾아보기
  • 나중에 안보면서 Todo list 구현해보고, 잘 안되는 부분은 암기하기
  • JAVA 공부하기
  • JPA repository method 작성법 보기
  • Servlet 유튜브 찾아보기
  • SecurityContext::Authentication에서 Authentication이 메소드는 아닌 것 같은데 뭔지 알아보기
  • Generic 공부 다시 하기
  • Jwt..Filter 내의 doFilterInternal 함수 속 {} 쓰레드에 SecurityContext를 하나만 등록가능 해서 setAuthentication으로 등록한 것인지

 

알게된 점

  • String.substring(n)
//예제
String str = "ABCDEFG"; 

str.substring(3); 
/*substring(시작위치) 결과값 = DEFG*/

 

출처: React, 스프링, AWS로 배우는 웹 개발 101

'React, 스프링, AWS로 배우는 웹개발 101' 카테고리의 다른 글

인증 프론트엔드 통합  (0) 2022.04.08
프로덕션 배포  (0) 2022.04.07
프론트엔드 개발  (0) 2022.03.16
백엔드 개발  (0) 2022.02.27
ToDoList APP을 개발하기 전  (0) 2022.02.26