본문 바로가기
개발/Spring

로그인과 JWT 발급

by solchan98 2022. 9. 28.

깃허브 저장소

 

GitHub - solchan98/Playground: 🛝 개발 공부 놀이터 🛝

🛝 개발 공부 놀이터 🛝. Contribute to solchan98/Playground development by creating an account on GitHub.

github.com

로그인 및 토큰 발급

  • 2022-09-28 새로 작성되었습니다.

스프링 시큐리티를 활용하여 로그인을 진행하고, JWT를 발급하여 응답해보자.

진행 시나리오

  1. 회원가입
  2. 로그인 및 AccessToken발급

진행은 우선 회원가입을 진행하여 데이터베이스에 정보를 저장하고, 로그인을 진행하여 검증에 성공하면 AccessToken을 발급하여 응답하는 것으로 마무리한다.

프로젝트 구조

프로젝트의 dependencies는 간단하게 다음과 같이 구성되었다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'mysql:mysql-connector-java'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'

    // [JWT]
    implementation 'io.jsonwebtoken:jjwt:0.9.1'
}

진행 시나리오를 진행하면서 아래 프로젝트 구조를 하나씩 만들어나간다.

 

프로젝트 구조

기본적인 Config 작성

사용자의 접근을 통제하기 위해 Spring Security를 사용한다.
간단하게 Spring Security의 동작원리를 알고있으면 쉽게 진행할 수 있다.

이전에는 AppConfig와 SecurityConfig를 따로 작성지만 이번 진행에서는 Security의 FilterChain 수정만 필요하기 때문에 간단하게 통합으로 설정을 작성하였다.

@Configuration 어노테이션을 통해 AppConfig를 설정파일로 등록한다.
@EnableWebSecurity를 통해 Security 설정을 활성화시킨다.

  • PasswordEncoder
    • 회원가입을 할 때, 데이터베이스에 사용자가 전달해준 비밀번호를 그대로 저장하면 안된다.
    • 따라서 PasswordEncoder를 Bean으로 등록하여 회원가입 진행 시, 비밀번호를 해시하여 저장한다.
  • SecurityFilterChain
    • Spring Security는 FilterChain 방식으로 인증 과정이 진행된다.
    • cors와 csrf를 disable시킨다. (cors와 csrf가 뭔데?)
    • authorizeRequests()를 통해 인증된 사용자만 접근할 수 있도록 설정한다.
    • antMatchers를 통해 특정 URI(로그인 및 회원가입)는 누구나 접근 가능하도록 설정한다.
    • 기본적으로 UsernamePasswordAuthenticationFilter가 우선적으로 걸리는데 이 보다 앞에 추후에 직접 작성할 CustomJwtFilter를 설정하도록 할 예정이다.

아래의 위치에 config를 작성한다.
/springbootjwt/config/AppConfig.java

@Configuration
@EnableWebSecurity
public class AppConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable()
                .authorizeRequests()
                .antMatchers("/account/sign-up", "/account/login").permitAll()
                .anyRequest().authenticated();
        return http.build();
    }
}

프로젝트 설정 파일(application.yml) 작성
데이터베이스 연결을 위해 프로젝트 설정 파일에 설정값을 작성해야한다.


본 게시글은 MySQL을 사용하였으나 사용하는 DB가 다르다면 해당 DB에 맞는 설정을 진행하면 된다.

참고로 아래 설정에는 SQL을 보기 쉽게 포매팅해주는 설정(spring.jpa...) 또한 추가되어있다.

 

spring 하위의 jwt 부분이 토큰 발급에 관여하는 설정값이다.
atk의 live를 300000(1000 = 1s) 즉, 5분으로 설정하였다.

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/jwt?serverTimezone=Asia/Seoul
    username: root
    password: 1234

  jwt:
    key: secret-key
    live:
      atk: 300000

  jpa:
    database: mysql
    hibernate:
      ddl-auto: update
    show-sql: true
    properties:
      hibernate:
        format_sql: true

회원가입

우리는 AppConfig를 통해 /account/sign-up의 요청은 누구나 요청 가능하도록 설정하였다.

  • 회원가입은 간단하게 이메일과 닉네임, 비밀번호를 입력받아 구현한다.
  • 이메일, 닉네임, 비밀번호에 대한 검증은 매 구현마다 정책이 다르기 때문에 이번에는 진행하지 않는다.

Account 도메인 작성

  1. Account 도메인을 작성한다.
  2. AccountRepository를 작성한다.

Account 도메인 작성

Account는 다음의 위치에 생성한다.
/springbootjwt/account/Account.java

아래의 Account 도메인은 기본적으로 구성되었으며 기획에 따라 추가 수정 가능하다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Account {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String email;

    private String password;

    private String nickname;

    public Account(String email, String password, String nickname) {
        this.email = email;
        this.password = password;
        this.nickname = nickname;
    }
}

Account Repository 작성
AccountRepositry는 다음의 위치에 생성한다.
/springbootjwt/account/AccountRepository.java

public interface AccountRepository extends JpaRepository<Account, Long> {

    boolean existsByEmail(String email);

    Optional<Account> findByEmail(String email);
}

회원가입 비즈니스 로직 작성

이제 회원가입을 위한 비즈니스 로직을 작성한다.
AccountService는 다음의 위치에 생성한다.
/springbootjwt/account/AccountService.java

 

회원가입 시나리오는 다음과 같다.

  • 이메일 중복 체크
  • 비밀번호 해시
  • 회원가입

AppConfig를 통해 등록했던 PasswordEncoder와 AccountRepository를 주입받아 사용한다.

아래의 new BadRequestException(...)은 좀 더 아래에서 통합 예외 처리를 작성할 예정이다.

@Service
@RequiredArgsConstructor
public class AccountService {

    private final AccountRepository accountRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public AccountResponse signUp(SignUpRequest signUpRequest) {
        boolean isExist = accountRepository
                .existsByEmail(signUpRequest.getEmail());
        if (isExist) throw new BadRequestException("이미 존재하는 이메일입니다.");
        String encodedPassword = passwordEncoder.encode(signUpRequest.getPassword());

        Account account = new Account(
                signUpRequest.getEmail(),
                encodedPassword,
                signUpRequest.getNickname());

        account = accountRepository.save(account);
        return AccountResponse.of(account);
    }
}

회원가입 컨트롤러 작성

이제 회원가입을 위한 컨트롤러를 작성한다.

회원가입 요청을 위한 DTO를 우선 작성한다.
SignUpRequest는 다음의 위치에 생성한다.
/springbootjwt/account/SignUpRequest.java

@Getter
@AllArgsConstructor
public class SignUpRequest {

    private final String email;

    private final String password;

    private final String nickname;
}

회원가입에 성공하면 아주 간단하게 유저의 정보를 응답한다.
따라서 범용적으로 사용 가능한 AccountResponse DTO를 아래의 위치에 만든다.
/springbootjwt/account/AccountResponse.java

@Getter
public class AccountResponse {

    private final Long accountId;

    private final String email;

    private final String nickname;

    private AccountResponse(Long accountId, String email, String nickname) {
        this.accountId = accountId;
        this.email = email;
        this.nickname = nickname;
    }

    public static AccountResponse of(Account account) {
        return new AccountResponse(
                account.getId(),
                account.getEmail(),
                account.getNickname());
    }
}

AccountController는 다음의 위치에 생성한다.
/springbootjwt/account/AccountController.java

@RestController
@RequestMapping("/account")
@RequiredArgsConstructor
public class AccountController {

    private final AccountService accountService;
    private final JwtProvider jwtProvider;

    @PostMapping("/sign-up")
    public AccountResponse signUp(
            @RequestBody SignUpRequest signUpRequest
    ) {
        return accountService.signUp(signUpRequest);
    }
}

통합 예외 처리

간단하게 통합 예외 처리를 위한 부분을 작성한다.
통합 예외 처리에 대한 자세한 부분은 추가로 다루지 않는다.

  • Message
  • BadRequestException
  • GlobalExceptionHandler

디렉토리 위치
/springbootjwt/exception/*

Message

@Getter
@AllArgsConstructor
public class Message {
    private String message;
    private HttpStatus status;
}

BadRequestException

@Getter
public class BadRequestException extends RuntimeException{
    public BadRequestException(String message) {
        super(message);
    }
}

GlobalExceptionHanlder

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BadRequestException.class)
    public ResponseEntity<Message> handle(BadRequestException e) {
        Message message = new Message(e.getMessage(), HttpStatus.BAD_REQUEST);
        return new ResponseEntity<>(message, HttpStatus.BAD_REQUEST);
    }
}

회원가입 테스트

정상 회원가입

회원가입 성공

중복 회원가입

위에서 작성한 통합 예외 처리에 따라 예외에 대한 응답을 한다.

이메일 중복

로그인

회원가입에 성공하였으니 로그인을 통해 토큰을 발급해보자.

로그인의 시나리오는 다음과 같다.

  • 가입된 유저인지 아이디와 비밀번호 검증 (Service Layer)
  • AccessToken 발급 (Controller Layer)

가입된 유저인지 아이디와 비밀번호 검증 (Service Layer)

사용자로부터 전달받은 아이디와 비밀번호를 우선 검증한다.

  • 전달받은 아이디가 존재하는 유저인지 확인한다.
  • 비밀번호가 일치한지 확인한다.
  • 검증에 성공하면 AccountService는 AccountResponse DTO를 Controller에게 응답한다.

아이디 및 비밀번호 검증 과정을 AccountService에 새롭게 추가한다.

AccountService

@Service
@RequiredArgsConstructor
public class AccountService {

    private final AccountRepository accountRepository;
    private final PasswordEncoder passwordEncoder;

    ...

    @Transactional(readOnly = true)
    public AccountResponse login(LoginRequest loginRequest) {
        Account account = accountRepository
                .findByEmail(loginRequest.getEmail())
                .orElseThrow(() -> new BadRequestException("아이디 혹은 비밀번호를 확인하세요."));

        boolean matches = passwordEncoder.matches(
                loginRequest.getPassword(),
                account.getPassword());
        if (!matches) throw new BadRequestException("아이디 혹은 비밀번호를 확인하세요.");

        return AccountResponse.of(account);
    }
}

AccessToken 발급 (Controller Layer)

Service Layer(AccountService)에서 로그인 검증에 성공하였고, AccountResponse DTO를 응답하였다.

이제 토큰을 발급하면 된다.

 

AccessToken을 발급하기 위해서 JwtProvider를 작성해야한다.
JwtProvider는 token에 대한 생성 및 관리를 담당하는 부분이다.

 

이번에는 token 생성에 관한 부분을 작성한다.

우선, JWT의 Payload에 들어갈 Subject 객체를 생성한다.
/springbootjwt/jwt/Subject.java

생성자가 'atk', 'rtk' 2개가 존재한다. 추후 refresh token에 대하여 진행할 때 사용된다.

(type 필드가 존재하는 이유는 atk와 rtk의 혼용 사용을 막기위함이다.)

@Getter
public class Subject {

    private final Long accountId;

    private final String email;

    private final String nickname;

    private final String type;

    private Subject(Long accountId, String email, String nickname, String type) {
        this.accountId = accountId;
        this.email = email;
        this.nickname = nickname;
        this.type = type;
    }

    public static Subject atk(Long accountId, String email, String nickname) {
        return new Subject(accountId, email, nickname, "ATK");
    }

    public static Subject rtk(Long accountId, String email, String nickname) {
        return new Subject(accountId, email, nickname, "RTK");
    }
}

이제 토큰을 발급하면 응답할 TokenResponse DTO를 작성한다.

/springboot/jwt/TokenResponse
atk, rtk 2개의 필드를 가지며, 이번 포스팅에서는 atk만 사용한다.

@Getter
@AllArgsConstructor
public class TokenResponse {

    private final String atk;

    private final String rtk;
}

이제 JwtProvider를 작성한다.
/springboot/jwt/JwtProvider.java

JwtProvider

@Component
@RequiredArgsConstructor
public class JwtProvider {

    private final ObjectMapper objectMapper;

    @Value("${spring.jwt.key}")
    private String key;

    @Value("${spring.jwt.live.atk}")
    private Long atkLive;

    @PostConstruct
    protected void init() {
        key = Base64.getEncoder().encodeToString(key.getBytes());
    }

    public TokenResponse createTokensByLogin(AccountResponse accountResponse) throws JsonProcessingException {
        Subject atkSubject = Subject.atk(
                accountResponse.getAccountId(),
                accountResponse.getEmail(),
                accountResponse.getNickname());
        String atk = createToken(atkSubject, atkLive);
        return new TokenResponse(atk, null);
    }

    private String createToken(Subject subject, Long tokenLive) throws JsonProcessingException {
        String subjectStr = objectMapper.writeValueAsString(subject);
        Claims claims = Jwts.claims()
                .setSubject(subjectStr);
        Date date = new Date();
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(date)
                .setExpiration(new Date(date.getTime() + tokenLive))
                .signWith(SignatureAlgorithm.HS256, key)
                .compact();
    }
}

AccountService를 통해 전달 받은 AccountResponse DTO를 JwtProvider에게 넘겨주어 토큰을 발행한다.
토큰은 발행 유저 정보, 발행 시간, 유효 시간, 그리고 해싱 알고리즘과 키를 설정하여 발행한다.

 

이제 AccountController에 로그인을 위한 메서드를 작성한다.

login 메서드의 응답으로 TokenResponse를 응답한다.


추가적인 유저의 정보 없이 토큰만 응답하는 이유는 access token의 payload에 이미 유저의 정보가 들어있기 때문이다.

 

AccountController

@RestController
@RequestMapping("/account")
@RequiredArgsConstructor
public class AccountController {

    private final AccountService accountService;
    private final JwtProvider jwtProvider;

    ...

    @PostMapping("/login")
    public TokenResponse login(
            @RequestBody LoginRequest loginRequest
    ) throws JsonProcessingException {
        AccountResponse accountResponse = accountService.login(loginRequest);
        return jwtProvider.createTokensByLogin(accountResponse);
    }
}

AccessToken 발급 테스트

회원가입이 완료된 아이디와 비밀번호를 통해 다음과 같이 요청하면 정상적인 AccessToken이 발급되어 응답되는 것을 확인할 수 있다.

로그인 성공

잘못된 정보로 로그인 요청 테스트

잘못된 정보(다른 이메일 혹은 다른 비밀번호)로 로그인을 요청하는 경우, 아래와 같은 400 상태를 갖는 응답이 온다.

로그인 실패


JWT를 통한 로그인 포스팅
1. 로그인과 JWT 발급
2. 발급받은 JWT로 요청하기
3. Redis를 통한 JWT Refresh Token 관리
4. Redis를 통한 로그아웃 이후, AccessToken 관리 (BlackList)