[SpringBoot] 게시판 API 서버 만들기 - 로그인 및 회원 가입

Jae·2024년 6월 16일

spring-api-practice

목록 보기
2/3

개요

Json Web Token을 이용한 회원 가입 및 로그인 기능 구현
주로 검증 과정에 대해 포스팅하였습니다.

패키지 구조

요구 사항

  • 이메일, 비밀번호, 사용자 이름을 입력받아서 사용자 정보 생성
  • 사용자 등급은 일반 사용자와 관리자 두 가지이다.
  • 비밀번호는 암호화하여 저장한다.

Flowchart

회원가입

로그인

Entity

User

필드명타입설명
idLongpk
emailString이메일 주소
usernameString사용자 이름
passwordString비밀번호
rolesString회원 등급 정보
created_atLocalDateTime생성 일시
updated_atLocalDateTime수정 일시

계획

  1. 회원 정보 저장을 위한 과정 개발
  2. 응답 형태 및 예외 처리 공통화 과정
  3. password 암호화
  4. token 적용

과정

  1. 회원 정보 저장을 위한 과정 개발
  • 순차적으로 개발하기 위해 먼저 간단한 형태의 회원 가입 프로세스를 개발하였다.
    .
    └── com
           └── apipractice
               ├── ApiPracticeApplication.java
               ├── controller
               │   └── user
               │       ├── UserController.java
               │       ├── request
               │       │   └── SignUpRequest.java
               │       └── response
               │           └── SignUpResponse.java
               ├── dto
               │   └── user
               │       └── UserDto.java
               ├── entity
               │   └── user
               │       └── User.java
               ├── enums
               │   └── UserRole.java
               ├── repository
               │   └── user
               │       └── UserRepository.java
               └── service
                   └── user
                       └── UserService.java

entity, enum, dto, repository, service, controller, request, response를 만들어서 요청을 하면 회원가입이 완료된다.

기존의 롬복 설정에서 시큐리티가 적용되어 있어서 호출이 안 되는 문제가 발생했는데 아래의 설정을 추가해서 해결하였다.

@SpringBootApplication(exclude={SecurityAutoConfiguration.class})

  1. 예외처리 내용 추가
  • 중복된 이메일이 가입되면 안 되기 때문에 DB에서 조회해서 중복되면 예외처리하도록하였다.
  • 예외처리는 공통화해서 사용할 수 있도록 하였다.
.
└── com
        └── apipractice
            ├── controller
            │   ├── response
            │   │   └── Response.java
            ├── exception
            │   ├── ApiPracticeApplicationException.java
            │   ├── CustomAuthenticationEntryPoint.java
            │   ├── ErrorCode.java
            │   └── GlobalControllerAdvice.java
            ├── repository
            │   └── user
            │       └── UserRepository.java
            └── service
                └── user
                    └── UserService.java
{ // 회원가입시 중복된 요청을 보내면 아래와 같음.
    "resultCode": "DUPLICATED_EMAIL",
    "result": null
}
  1. 날짜와 같은 공통 부분 추가
  • entity에 common 패키지를 만들고, 시간을 관리하는 엔티티를 만들었다.
package com.jejeong.apipractice.entity.common;

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import java.sql.Timestamp;
import java.time.Instant;
import lombok.Getter;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class EntityDate {

  @Column(name = "created_at")
  private Timestamp createdAt;

  @LastModifiedDate
  @Column(name = "updated_at")
  private Timestamp updatedAt;

  @Column(name = "removed_at")
  private Timestamp removedAt;

  @PrePersist
  void createAt() {
    this.createdAt = Timestamp.from(Instant.now());
  }

  @PreUpdate
  void updatedAt() {
    this.updatedAt = Timestamp.from(Instant.now());
  }
}
  • 이후 필요한 엔티티 클래스에 상속해주고, DTO나 Response에 변수를 추가해줬다.
public class User extends EntityDate {


-> 가입한 날짜가 기록된다.

  1. 이미 가입되어있는 사용자인 경우 가입 못하도록 변경 및 password 암호화
  • 비밀번호 암호화를 위해 암호화할 부분을 빈으로 만들어줬다.
// SecurityConfiguration.java
package com.jejeong.apipractice.configuration;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@Configuration
public class SecurityConfiguration {

  @Bean
  public BCryptPasswordEncoder encoderPassword() {
    return new BCryptPasswordEncoder();
  }
}
// UserService.java
  public UserDto join(SignUpRequest req) {

    userRepository.findByEmail(req.getEmail()).ifPresent((ip) -> {
      throw new ApiPracticeApplicationException(
          ErrorCode.DUPLICATED_EMAIL, String.format("email is %s", req.getEmail()));
    });
    String password = passwordEncoder.encode(req.getPassword());
    User user = userRepository.save(User.of(req.getEmail(), password, req.getUsername()));
    return UserDto.fromEntity(user);
  }
  1. token, security 적용
    먼저, 토큰을 사용하는 이유는 HTTP의 특성인 비연결지향관점에서 세션을 사용하게 되면 서버에 부담이 가고, 서버에 포함되어있는데 토큰은 요청 및 응답에 담을 수 있는 특성이 있어 사용한다.
    1) 로그인이 완료되어야 토큰을 발급할 수 있어서 로그인을 사용자 인증까지 완료하는 서비스로직까지 만들어주었다.
  public SignInResponse signIn(SignInRequest req) {
    UserDto userDto = loadUserByEmail(req.getEmail());
    if (!passwordEncoder.matches(req.getPassword(), userDto.getPassword())) {
      throw new ApiPracticeApplicationException(ErrorCode.INVALID_PASSWORD);
    }

    String accessToken = jwtTokenUtils.generateAccessToken(userDto.getEmail());
    String refreshToken = jwtTokenUtils.generateRefreshToken(userDto.getEmail());
    return new SignInResponse(userDto.getEmail(), accessToken, refreshToken);
  }

2) 실제 토큰을 발급하는 부분은 유틸성 클래스를 따로 만들어주었다.

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

@Component
public class JwtTokenUtils {

  @Value("${jwt.token.secret-key.access}")
  private String accessSecretKey;

  @Value("${jwt.token.secret-key.refresh}")
  private String refreshSecretKey;

  @Value("${jwt.token.expired-time-ms.access}")
  private Long accessExpiredTimeMs;

  @Value("${jwt.token.expired-time-ms.refresh}")
  private Long refreshExpiredTimeMs;

  public String generateAccessToken(String username) {
    return doGenerateToken(username, accessSecretKey, accessExpiredTimeMs);
  }

  public String generateRefreshToken(String username) {
    return doGenerateToken(username, accessSecretKey, refreshExpiredTimeMs);
  }


  private String doGenerateToken(String username, String key, long expiredTimeMs) {
    Claims claims = Jwts.claims();
    claims.put("username", username);

    return Jwts.builder()
        .setClaims(claims)
        .setIssuedAt(new Date(System.currentTimeMillis()))
        .setExpiration(new Date(System.currentTimeMillis() + expiredTimeMs))
        .signWith(SignatureAlgorithm.HS256, getSigningKey(key))
        .compact();
  }


  public Boolean validate(UserDetails userDetails, String token, String key) {
    String username = getUsername(token, key);
    return username.equals(userDetails.getUsername()) && !isTokenExpired(token, key);
  }

  public String getUsername(String token, String key) {
    return extractAllClaims(token, key).get("username", String.class);
  }

  public Claims extractAllClaims(String token, String key) {
    return Jwts.parserBuilder()
        .setSigningKey(getSigningKey(key))
        .build()
        .parseClaimsJws(token)
        .getBody();
  }

  private Key getSigningKey(String secretKey) {
    byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
    return Keys.hmacShaKeyFor(keyBytes);
  }

  public Boolean isTokenExpired(String token, String key) {
    Date expiration = extractAllClaims(token, key).getExpiration();
    return expiration.before(new Date());
  }

여기까지 하면, 로그인하고나서 토큰 생성까지 완료된다.

http://localhost:8080/api/v1/users/sign-in

{
    "resultCode": "SUCCESS",
    "result": {
        "email": "java@gmail.com",
        "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImphdmFAZ21haWwuY29tIiwiaWF0IjoxNzIyMjIxMTQ0LCJleHAiOjE3MjIyNDcwNjR9.k4FAmQidv38WfRSe8zricbng8jMPVIY2SuCUvCl_gB0",
        "refreshToken": "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImphdmFAZ21haWwuY29tIiwiaWF0IjoxNzIyMjIxMTQ0LCJleHAiOjE3MjQ4MTMxNDR9.hdHXVQVyGzZxAlz3hjtpv-mxS3eczTwfv2GuzKXNJYk"
    }
}

코드

https://github.com/jaeeunjeong/auth-platform

경험한 이슈

Spring Security 5.7.0-M2 이후 WebSecurityConfigurerAdapter 사용이 불가능하다.

관련된 부분은 아래 링크를 확인하면 된다.
https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter

수정

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

  @Bean
  public DefaultSecurityFilterChain configure(HttpSecurity http) throws Exception {
    http.httpBasic(AbstractHttpConfigurer::disable);
    http.formLogin(AbstractHttpConfigurer::disable);
    http.csrf(AbstractHttpConfigurer::disable);
    http.sessionManagement(
        manager -> manager.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
    http.authorizeHttpRequests(request -> request.requestMatchers("/**").permitAll());
    return http.build();
  }
}

버전 업으로 인해 Controller쪽의 DTO에 수동으로 default constructor를 만들어줘야했다.

spring 3.2.0 이후에 왜 그런진 못 찾았지만, 생성자 바인딩시 기본 생성자로 바인딩해줘야하는데
롬복 어노테이션이 되질 않아서 수동으로 만들어줬다.

회고

프로젝트 뼈대를 하나하나 만들어야하고, 테스트 코드를 하나하나 진행해가면서 해가다보니 시간이 생각보다 많이 걸렸다.
게다가 최신 스프링부트 버전이나 시큐리티를 사용하다보니 기존에 참고할 자료가 deprecate된게 많아서 찾는데 어려움을 겪었다.
사실 커밋이나 블로그에 포스팅하기까지 토큰을 이용한 처리를 완료해서 올리고 싶었는데 일단 회원가입/ 로그인 기능 구현을 먼저 포스팅을 하기로 변경하였다...

0개의 댓글