토이프로젝트

[팀프로젝트]모바일앱 게시판 만들기 - 2편 : 로그인/회원가입 개발하기

  • -
반응형

지난 글에서 게시판 만들기에 필요한 테이블들을 설계해봤습니다.

이번 글부터 설계한 테이블을 토대로 로그인/회원가입 API를 만들어보겠습니다.

개발 환경을 참고해 주세요.

  • Spring Boot 2.7.5
  • Jdk 1.8
  • Gradle

1. 인증 시나리오

본격적인  개발에 앞서 회원 인증을 어떤 식으로 구현할지 고민했습니다.

회원 가입에 필요한 데이터들을 최소화하고 카카오 로그인만 제공하기로 했습니다.(아직 수정된 내용을 배포해보지 않아서 카카오 로그인만 제공할 때 반려가 될지는 모르겠네요)

 

카카오 로그인을 통해 전달받은 데이터는 카카오 아이디와 프로필 이미지만 사용했습니다. 추가적으로 사용자로부터 닉네임, 도매시장, 법인을 입력받았습니다.

처음 생각한 시나리오는 다음과 같습니다.

 

  • 웹/모바일앱에서 각각 네이티브 방식으로 카카오 인가/인증을 진행
  • 카카오 고유 아이디를 담아 API 서버를 호출하고, 해당 사용자가 회원인지 비회원인지를 구분한다.
  • 회원이라면 JWT 토큰(액세스 토큰, 리프레쉬 토큰)을 반환하고, 비회원이라면 토큰값이 null로 반환한다.
  • 회원은 다음 스탭으로 넘어가며, 비회원은 회원가입 페이지로 리다이렉트 한다.

 

토큰 관리/사용은 다음과 같이 정리했습니다.

  • 액세스 토큰의 만료 시간은 30분으로 설정하고 리프레쉬 토큰의 만료 시간은 30일로 설정한다.
  • 액세스 토큰과 리프레쉬 토큰을 생성하면 DB에 저장하고, API 호출시 헤더에는 액세스 토큰을 쿠키에 리프레쉬 토큰을 포함시킨다.
  • 액세스 토큰이 만료되면 쿠키에 포함된 리프레쉬 토큰액세스 토큰을 통해 조회한 리프레쉬 토큰(DB에서 조회)이 같은지를 체크한다.
  • 값이 같다면 리프레쉬 토큰이 만료되었는지를 체크한다. 리프레쉬 토큰이 만료되지 않았다면 액세스 토큰을 갱신시키고, 아니라면 로그인으로 리다이렉트 한다.
    값이 다르다면 로그인으로 리다이렉트 한다.

 

위와 같이 토큰을 DB에 저장하게 된 이유는 토큰의 탈취 가능성 때문이었습니다.

액세스 토큰이 탈취된 경우 해당 토큰으로 접근 권한을 가질 수 있습니다. 이를 방지하기 위해 유효시간을 짧게 잡았고, 액세스 토큰 발급시에 생성된 리프레쉬 토큰을 DB에 저장함으로써 갱신에 사용되는 리프레쉬 토큰 또한 검증할 수 있게 했습니다.

모바일 특성상 로그인을 매번할 필요가 없기에 서비스를 매일 사용한다면 리프레쉬 토큰으로 인해 이론상 로그인이 쭉 유지되게 됩니다.

 

발급 받은 토큰은 Flutter secure storage에 저장하기로 했습니다. secure storage는 앱 내에 민감한 데이터를 안전하게 보관하는데 사용되는 도구로 사용자 정보, 토큰 등을 저장하는데 사용됩니다.

 

인증이 필요한 API를 정리했습니다.

농실농실앱의 주요 기능은 실시간 경락자 조회, 정산 가격 조회, 통계 조회입니다. 이 부분은 로그인없이 볼 수 있어야 했고, 게시판 리스트 또한 로그인없이 볼 수 있으면 좋을 것 같았습니다. 그리고 게시판위에 레이어를 깔고 블러 처리를 해서 로그인을 유도하게끔 계획했습니다.

 

 

2. 테이블 생성

회원과 토큰 테이블을 생성하겠습니다. (탈퇴 회원 테이블은 앞서 얘기했듯 아직 구현이 되지 않았기 때문에 다루지 않겠습니다)

회원과 토큰 테이블은 회원 번호(USER_NO)로 연결됩니다.

 

CREATE TABLE `USERS` (
  `USER_NO` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '회원고유번호',
  `USER_ID` varchar(30) COLLATE utf8mb4_bin NOT NULL COMMENT '아이디',
  `USER_NAME` varchar(50) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '이름',
  `MARKET_CODE` varchar(10) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '도매시장코드',
  `COMPANY_CODE` varchar(10) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '법인코드',
  `PROFILE_IMAGE_URL` text COLLATE utf8mb4_bin COMMENT '프로필이미지',
  `CREATE_DATE` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
  PRIMARY KEY (`USER_NO`),
  KEY `users_USER_ID_IDX` (`USER_ID`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='회원';

CREATE TABLE `USERS_TOKEN` (
  `USER_NO` bigint(20) NOT NULL COMMENT '회원고유번호',
  `ACCESS_TOKEN` varchar(256) COLLATE utf8mb4_bin NOT NULL COMMENT '액세스토큰',
  `REFRESH_TOKEN` varchar(256) COLLATE utf8mb4_bin NOT NULL COMMENT '리프레쉬토큰',
  `CREATE_DATE` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
  PRIMARY KEY (`USER_NO`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='회원 토큰';

 

 

3. 로그인 API 만들기

3-1. 의존성 추가

프로젝트에서 필요로 하는 의존성 리스트입니다.

  • Spring Security
  • Jsonwebtoken
  • lombok
  • Apache commons
  • mysql-connector
  • mapstruct
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-web-services'
implementation 'org.springframework.boot:spring-boot-starter-security'

implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly	'io.jsonwebtoken:jjwt-jackson:0.11.5'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'

// Apache Commons
implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.12.0'

// MySQL
implementation 'mysql:mysql-connector-java'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

// Mapstruct
implementation 'org.mapstruct:mapstruct:1.4.2.Final'
annotationProcessor "org.mapstruct:mapstruct-processor:1.4.2.Final"
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'

 

3-2. properties 추가

개발 환경에서 사용할 application-dev.properties를 추가합니다.

dev properties를 사용해 서버를 실행하는 방법은 더보기를 참고해 주세요.

더보기

 

Active profiles에 dev 추가

spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/DB스키마?useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&serverTimezone=Asia/Seoul
spring.datasource.username=root
spring.datasource.password=0000

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl

logging.level.org.hibernate.SQL=debug
logging.level.org.hibernate.type=trace

jwt.token.secret=JWT 토큰 생성시 사용되는 서명키로 50자 이상 입력해주세요

 

 

하나씩 클래스를 추가해보겠습니다. 프로젝트의 구조는 아래와 같습니다.

.
└── com/
    └── api/
        └── nongsil/
            ├── common/
            │   ├── component
            │   ├── dto
            │   ├── exception
            │   ├── filter
            │   └── utils
            ├── config/
            │   ├── WebConfig
            │   └── WebSecurityConfig
            └── domain/
                ├── board
                ├── oauth
                └── user

 

3-3. Entity, Dto, Mapper 추가

Entity에서는 데이터 무결성을 위해 @Setter는 제외했고 업데이트는 빌더 패턴을 이용했습니다.

Dto에서는 편의상 @Data를 사용했습니다.

 

[Entity]

@Getter
@Entity
@Builder(toBuilder = true)
@Table(name = "USERS")
@AllArgsConstructor
@NoArgsConstructor
public class Users {

    @Id
    @Column(name = "USER_NO")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long userNo;
    @Column(name = "USER_ID")
    private String userId;
    @Column(name = "USER_NAME")
    private String userName;
    @Column(name = "MARKET_CODE")
    private String marketCode;
    @Column(name = "COMPANY_CODE")
    private String companyCode;
    @Column(name = "PROFILE_IMAGE_URL")
    private String profileImageUrl;
    @Column(name = "CREATE_DATE")
    @UpdateTimestamp
    private LocalDateTime createDate;
}
@Getter
@Entity
@Builder(toBuilder = true)
@Table(name = "USERS_TOKEN")
@AllArgsConstructor
@NoArgsConstructor
public class UsersToken {

    @Id
    @Column(name = "USER_NO")
    private Long userNo;
    @Column(name = "ACCESS_TOKEN")
    private String accessToken;
    @Column(name = "REFRESH_TOKEN")
    private String refreshToken;
    @Column(name = "CREATE_DATE")
    @UpdateTimestamp
    private LocalDateTime createDate;
}

 

[Dto]

@Data
@Builder
public class TokenDto {

    private String accessToken;
    private String refreshToken;

}
@Data
@Builder
public class UserDto {

    private Long userNo;
    private String userId;
    private String userName;
    private String marketCode;
    private String marketName;
    private String companyCode;
    private String companyName;
    private String profileImageUrl;
    private LocalDateTime createDate;
}

 

[Mapper]

Dto와 Entity를 매핑하기 위해 MapStruct 라이브러리를 이용했습니다. MapStruct를 사용하는 이유는 여러가지가 있는데  매핑 오류에 대한 디버깅이 쉽기도 하고(컴파일 시점에 코드가 생성) 속도가 다른 매핑 라이브러리보다 빠릅니다.

@Mapper(componentModel = "spring")
public interface UserMapper {

    UserDto toDto(Users entity);

    Users toEntity(UserDto dto);

}

 

Mapper 파일을 추가하고 빌드를 해보면 MapperImpl 파일이 생성되는 것을 확인할 수 있습니다.

 

3-4. Repository 추가

UserRepository

@Repository
public interface UserRepository extends JpaRepository<Users, Long> {

    Users findByUserId(String userId);

    boolean existsByUserName(String userName);

}

 

UserTokenRepository

@Repository
public interface UserTokenRepository extends JpaRepository<UsersToken, Long> {

    UsersToken findByUserNo(long userNo);

    UsersToken findByAccessToken(String accessToken);
}

 

3-5. JwtTokenProvider 추가

JwtTokenProvider는 JWT 토큰 발행, 복호화, 유효성 체크 기능을 갖고 있습니다.

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    @Value("${jwt.token.secret}")
    private String SECRET;
    private SecretKey secretKey;

    // Access Token 만료 시간 : 30분
    private final static long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30L;
    // Refresh token 만료 시간 : 30일
    private final static long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60L * 24 * 30;

    @PostConstruct
    private void setSecretKey() {
        // 서버 최초 로드시에 HMAC 알고리즘이 적용된 JWT 서명키를 생성한다.
        secretKey = Keys.hmacShaKeyFor(SECRET.getBytes());
    }

    /**
     * Access Token 생성
     *
     * @param userNo
     * @return
     */
    public String generateAccessToken(Long userNo) {
        return createToken(userNo.toString(), ACCESS_TOKEN_EXPIRE_TIME);
    }

    /**
     * Refresh token 생성
     *
     * @return
     */
    public String generateRefreshToken() {
        return createToken("", REFRESH_TOKEN_EXPIRE_TIME);
    }

    /**
     * 토큰 생성
     *
     * @param payload
     * @param expireTime
     * @return
     */
    public String createToken(
        String payload,
        long expireTime
    ) {
        Claims claims = Jwts.claims().setSubject(payload);
        Date now = new Date();
        Date validity = new Date(now.getTime() + expireTime);

        return Jwts.builder()
            .setHeaderParam("typ", "JWT")
            .setClaims(claims)
            .setIssuedAt(now)       // 발급시간
            .setExpiration(validity)// 만료시간
            .signWith(secretKey)
            .compact();
    }

    /**
     * 토큰 복호화
     *
     * @param accessToken
     * @return
     */
    public Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(secretKey)
                .build().parseClaimsJws(accessToken)
                .getBody();
        } catch (ExpiredJwtException e) {
            throw new AuthException(AuthEnum.EXPIRED.getMessage());
        }
    }

    /**
     * 토큰 유효성 체크
     *
     * @param token
     * @return
     */
    public AuthEnum isValidateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(secretKey)
                .build().parseClaimsJws(token);
            return AuthEnum.SUCCESS;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            return AuthEnum.MALFORMED;
        } catch (ExpiredJwtException e) {
            return AuthEnum.EXPIRED;
        } catch (UnsupportedJwtException e) {
            return AuthEnum.UNSUPPORTED;
        } catch (IllegalArgumentException e) {
            return AuthEnum.ILLEGAL;
        }
    }
}

 

3-6. JwtAuthenticationFilter 추가

API 호출시에 사용되는 토큰은 필터 레벨에서 체크하게 됩니다.

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String REFRESH_TOKEN_COOKIE = "refresh-token";
    private final JwtTokenProvider jwtTokenProvider;
    private final OauthService oauthService;

    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain filterChain
    ) throws ServletException, IOException {
        try {
            String accessToken = getTokenFromHeader(request);

            if (StringUtils.hasText(accessToken)) {
                AuthEnum authEnum = jwtTokenProvider.isValidateToken(accessToken);

                if (AuthEnum.SUCCESS.equals(authEnum)) {
                    authenticateWithToken(accessToken);
                } else {
                    Cookie refresh = WebUtils.getCookie(request, REFRESH_TOKEN_COOKIE);

                    /**
                     * 액세스 토큰이 만료된 경우 리프레쉬 토큰을 통해 갱신한다.
                     * 리프레쉬 토큰이 없다면 로그인을 요청한다.
                     */
                    if (AuthEnum.EXPIRED.equals(authEnum) && refresh != null) {
                        TokenDto reissueToken = oauthService.reissueAccessToken(accessToken,
                            refresh.getValue());

                        Cookie reissueCookie = new Cookie(REFRESH_TOKEN_COOKIE,
                            reissueToken.getRefreshToken());
                        response.addCookie(reissueCookie);
                        response.setHeader(AUTHORIZATION_HEADER,
                            "Bearer " + reissueToken.getAccessToken());

                        authenticateWithToken(reissueToken.getAccessToken());
                    } else {
                        throw new AuthException(authEnum.getMessage());
                    }
                }
            } else {
                throw new AuthException(AuthEnum.UNAUTHORIZED.getMessage());
            }

            filterChain.doFilter(request, response);
        } catch (AuthException e) {
            handleException(response, e);
        }
    }

    /**
     * 토큰 인증 처리
     *
     * @param accessToken
     */
    private void authenticateWithToken(String accessToken) {
        // 인증된 사용자는 SecurityContext 에 저장하고 서비스에서 SecurityUtil() 호출을 통해 사용한다.
        Authentication authentication = oauthService.getUserInfoFromAccessToken(
            accessToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    private String getTokenFromHeader(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);

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

        return null;
    }

    /**
     * 필터 레벨에서 발생한 예외를 처리하기 위해 Exception 을 따로 처리한다.
     *
     * @param response
     * @param e
     * @throws IOException
     */
    private void handleException(HttpServletResponse response, AuthException e) throws IOException {
        // 권한 예외 발생시 401 코드 리턴
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter()
            .write(new ObjectMapper().writeValueAsString(new Message(e.getMessage())));
    }
}

 

코드를 보면 앞서 계획한대로 쿠키에 담긴 리프레쉬 토큰 값을 통해 액세스 토큰을 갱신하는 로직이 담겨있습니다. 뒤에 OauthService에서 다시 살펴보겠습니다.

 

인증된 사용자는 SecurityContext에 저장되어 전역적으로 참조가 가능해집니다. 이 부분은 SecurityUtil 클래스를 통해 사용됩니다.

 

만약 인증되지 않은 사용자는 권한 예외를 반환하는데 이를 위해 AuthException을 추가로 생성하였고, Enum 클래스를 생성해 예외 메세지를 관리했습니다.

 

3-7. SecurityUtil 추가

JWT 토큰안에는 유니크한 회원 번호를 저장했습니다.

SecurityUtil().getUserNo()를 통해 인증된 사용자에 대한 정보를 알 수 있게 됩니다.

public class SecurityUtil {

    public static Long getUserNo() {
        final Authentication authentication = SecurityContextHolder.getContext()
            .getAuthentication();
        if (authentication == null) {
            throw new AuthException("접근 권한이 없습니다.");
        }

        UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();

        if (userPrincipal.getUserNo() == null) {
            throw new AuthException("접근 권한이 없습니다.");
        }

        return userPrincipal.getUserNo();
    }

}

 

3-8. AuthException, AuthEnum 추가

JwtTokenProvider의 isValidationToken 메소드를 통해 토큰의 상태를 Enum 메세지로 반환합니다.

public class AuthException extends RuntimeException {

    public AuthException(String msg) {
        super(msg);
    }
}

 

토큰의 상태는 6가지로 구분했습니다.

@Getter
public enum AuthEnum {

    SUCCESS("유효한 토큰입니다."),
    MALFORMED("잘못된 서명입니다."),
    EXPIRED("토큰이 만료되었습니다."),
    UNSUPPORTED("지원하지 않는 토큰입니다."),
    ILLEGAL("유효하지 않는 토큰입니다."),
    UNAUTHORIZED("접근 권한이 없습니다.");

    private final String message;

    AuthEnum(String message) {
        this.message = message;
    }
}

 

3-9. WebSecurityConfig 추가

Spring Security 설정 파일을 추가합니다. 패스워드를 사용하지 않기 때문에 PasswordEncoder는 없어도 무방합니다.

@Slf4j
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final OauthService oauthService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        /**
         * 인증 제외 경로
         * -회원가입, 로그인
         * -실거래, 정산결과 조회
         * -각 코드 조회
         */
        return web -> web.ignoring()
            .antMatchers("/user/join"
                , "/oauth/kakao"
                , "/trade/**"
                , "/market/**"
                , "/board/list"                
            );
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        log.info("================================");
        log.info(":: Run Spring security config ::");
        log.info("================================");

        httpSecurity

            /**
             * 토큰 인증 방식에서는 비활성화
             * csrf, 폼로그인, 세션 비활성화
             */
            .csrf().disable()
            .formLogin().disable()
            .httpBasic().disable()
            .rememberMe().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeHttpRequests()
            .anyRequest().authenticated()
            .and()
            .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, oauthService),
                UsernamePasswordAuthenticationFilter.class);

        return httpSecurity.build();
    }

}

 

Spring Security는 필터 기반으로 동작하게 되는데 addFilterBefore에 추가된 JwtAuthenticationFilter를 통해 UsernamePasswordAuthenticationFilter가 실행되기 전에 사용자의 인증 처리를 하게 됩니다. JwtAuthenticationFilter 안에서 사용자가 인증 상태가 되게 되면(SecurityContext에 저장) UsernamePasswordAuthenticationFilter에서는 이미 인증이 완료된 상태로 확인하여 필터를 통과합니다.

 

필터에서 실시간 경락자 조회, 정산 가격 조회, 통계 조회,회원가입, 로그인, 게시판 리스트 조회는 제외됩니다. 제외가 필요한 부분은 web.ignoring()에 추가할 수 있습니다.

 

3-10. OauthController 추가

@RestController
@RequiredArgsConstructor
@RequestMapping("oauth")
public class OauthController {

    private final OauthService oauthService;

    @PostMapping(value = "/kakao")
    public ResponseEntity<Message> kakaoAuthRequest(
        @RequestBody UserDto userDto
    ) {
        TokenDto tokenInfo = oauthService.getJwtToken(userDto);
        return ResponseEntity.ok().body(new Message("success", tokenInfo));
    }
}

 

3-11. OauthService 추가

카카오 인증/인가를 거쳐 얻게된 카카오 아이디를 받아 해당 사용자가 있는지 체크합니다. 만약 계정이 없다면 CustomException을 통해 에러 메세지를 반환합니다.

사용자가 있다면 JwtTokenProvider를 통해 액세스 토큰과 리프레쉬 토큰을 생성하고 갱신합니다.

@Service
@RequiredArgsConstructor
public class OauthService {

    private final UserRepository userRepository;
    private final UserTokenRepository userTokenRepository;
    private final JwtTokenProvider jwtTokenProvider;

    public TokenDto getJwtToken(UserDto userDto) {
        Users user = userRepository.findByUserId(userDto.getUserId());

        if (user == null) {
            throw new CustomException("계정이 존재하지 않습니다.");
        }

        String accessToken = jwtTokenProvider.generateAccessToken(user.getUserNo());
        String refreshToken = jwtTokenProvider.generateRefreshToken();

        // 토큰 갱신
        UsersToken existToken = userTokenRepository.findByUserNo(user.getUserNo());
        UsersToken updateToken;

        if (existToken == null) {
            updateToken = UsersToken.builder()
                .userNo(user.getUserNo())
                .accessToken(accessToken)
                .refreshToken(refreshToken).build();
        } else {
            updateToken = existToken.toBuilder()
                .accessToken(accessToken)
                .refreshToken(refreshToken).build();
        }

        userTokenRepository.save(updateToken);

        return TokenDto.builder()
            .accessToken(accessToken)
            .refreshToken(refreshToken).build();

    }

    public Authentication getUserInfoFromAccessToken(String accessToken) {
        Claims claims = jwtTokenProvider.parseClaims(accessToken);

        Long userNo = Long.valueOf(claims.getSubject());
        Users user = userRepository.findById(userNo)
            .orElseThrow(() -> new CustomException("계정이 존재하지 않습니다."));

        UserDetails userDetails = UserPrincipal.builder()
            .userNo(user.getUserNo())
            .userName(user.getUserId()).build();

        return new UsernamePasswordAuthenticationToken(userDetails, null,
            userDetails.getAuthorities());
    }

    /**
     * 액세스 토큰 재발급
     *
     * @param accessToken
     * @param refreshToken
     * @return
     */
    public TokenDto reissueAccessToken(
        String accessToken,
        String refreshToken
    ) {
        AuthEnum authEnum = jwtTokenProvider.isValidateToken(refreshToken);

        // 리프레쉬 토큰이 유효하지 않다면 로그인을 요청한다.
        if (!AuthEnum.SUCCESS.equals(authEnum)) {
            throw new AuthException(AuthEnum.UNAUTHORIZED.getMessage());
        }

        UsersToken userTokenInfo = userTokenRepository.findByAccessToken(accessToken);

        /**
         * 액세스 토큰으로 조회된 리프레쉬 토큰과 요청 토큰값이 같다면
         * 액세스, 리프레쉬 토큰을 재발급한다.
         */
        if (userTokenInfo != null && refreshToken.equals(userTokenInfo.getRefreshToken())) {
            String reissueAccessToken = jwtTokenProvider.generateAccessToken(
                userTokenInfo.getUserNo());
            String reissueRefreshToken = jwtTokenProvider.generateRefreshToken();

            // 재발급된 토큰 갱신
            UsersToken updateToken = userTokenInfo.toBuilder()
                .accessToken(reissueAccessToken)
                .refreshToken(reissueRefreshToken)
                .build();
            userTokenRepository.save(updateToken);

            return TokenDto.builder()
                .accessToken(reissueAccessToken)
                .refreshToken(reissueRefreshToken)
                .build();
        } else {
            throw new AuthException(AuthEnum.UNAUTHORIZED.getMessage());
        }
    }
}

 

reissueAccessToken은 액세스 토큰과 리프레쉬 토큰을 받아 리프레쉬 토큰의 유효성을 체크하고 액세스 토큰과 리프레쉬 토큰을 갱신/재발급합니다. 이 메소드는 액세스 토큰이 만료되었고 리프레쉬 토큰이 존재할 때 실행됩니다.

 

3-12. 테스트

서버를 실행하고 포스트맨을 통해 로그인 API를 실행해보겠습니다.카카오 로그인을 통해 전달받은 아이디를 전송해봅니다.

[비회원]

 

[회원]

 

 

토큰을 확인해보면 회원 번호가 조회됩니다.

 

 

4. 회원가입 API 만들기

4-1. UserController 추가

로그인 후 비회원은 회원가입을 진행합니다. 

@RestController
@RequestMapping("user")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    /**
     * 회원가입
     *
     * @param userDto
     * @return
     */
    @PostMapping(value = "/join")
    public ResponseEntity<Message> saveUserInfo(
        @RequestBody UserDto userDto
    ) {
        TokenDto tokenInfo = userService.saveUserInfo(userDto);
        return ResponseEntity.ok().body(new Message("success", tokenInfo));
    }
}

 

4-2. UserService 추가

saveUserInfo() 메소드를 통해 회원가입이 성공하면 토큰 테이블에 회원 번호로 신규 토큰이 등록되며 발행됩니다.

@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserTokenRepository userTokenRepository;
    private final UserRepository userRepository;
    private final UserMapper userMapper;
    private final JwtTokenProvider jwtTokenProvider;

    @Transactional
    public TokenDto saveUserInfo(UserDto userDto) {
        // 중복 닉네임 체크
        if (userRepository.existsByUserName(userDto.getUserName())) {
            throw new CustomException("중복된 닉네임이 존재합니다.");
        }

        Users userInfo = userRepository.findByUserId(userDto.getUserId());

        if (userInfo != null) {
            // 탈퇴 계정 체크
            if (withdrawUserRepository.existsByUserNo(userInfo.getUserNo())) {
                throw new CustomException("탈퇴한 계정이 존재합니다. 관리자에게 문의해 주세요.");
            }

            // 중복 계정 체크
            if (userInfo.getUserId().equals(userDto.getUserId())) {
                throw new CustomException("중복된 계정이 존재합니다.");
            }
        }

        try {
            // 회원가입
            Users user = userMapper.toEntity(userDto);
            userRepository.save(user);

            String accessToken = jwtTokenProvider.generateAccessToken(user.getUserNo());
            String refreshToken = jwtTokenProvider.generateRefreshToken();

            // JWT 토큰 저장
            UsersToken userToken = UsersToken.builder()
                .userNo(user.getUserNo())
                .accessToken(accessToken)
                .refreshToken(refreshToken).build();
            userTokenRepository.save(userToken);

            return TokenDto.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken).build();
        } catch (Exception e) {
            throw new CustomException("회원가입에 실패했습니다. 관리자에게 문의해 주세요.");
        }
    }
}

 

 

추가된 API
  • [POST] /oauth/kakao : 로그인
  • [POST] /user/join : 회원가입

여기까지 회원 체크를 위한 로그인/회원가입 API 기능을 구현해봤습니다.

다음 글에서는 Flutter에 카카오 로그인을 연동하고 회원가입까지 이어지는 과정을 알아보겠습니다.

본 포스팅은 스터디를 통해 작성하게 되었습니다. 피드백은 언제나 환영입니다 :)

반응형
Contents

포스팅 주소를 복사했습니다.

이 글이 도움이 되었다면 공감 부탁드립니다.