Trip-Linker 개발일지 4 - auth 도메인 전체 구현 — 로그인·토큰·비밀번호 재설정

2026. 6. 4. 19:34프로젝트/Trip-Linker 팀 프로젝트

반응형

auth 도메인 전체 구현 — 로그인·토큰·비밀번호 재설정

📋 한줄 요약

로그인·토큰 재발급·로그아웃·비밀번호 재설정 기능의 auth 도메인 전체를 구현했다.

JWT 발급 부분은 팀에서 JwtProvider를 완성하면 연결할 수 있도록 주석으로 자리를 잡아뒀다.


🎯 왜 만들었나

로그인 시 JWT를 발급하고, 5회 실패 시 계정을 잠그고, 비밀번호 재설정 링크를 이메일로 보내는 보안 흐름 전체가 필요했다.

auth 도메인에 필요한 Entity / Repository / DTO / Service / Controller를 한 번에 만들었다.


📁 작업 순서 (파일별)

1. LoginRequestDto.java

📦 domain/auth/dto

@Getter
@NoArgsConstructor
public class LoginRequestDto {
    private String username;
    private String password;
}

왜 만들었나

로그인 요청 Body를 Map<String, String>으로 받으면 타입 안전성이 없고 오타가 나도 컴파일 에러가 안 난다.

전용 DTO로 분리해서 필드명을 명시적으로 고정했다.


2. TokenResponseDto.java

📦 domain/auth/dto

@Getter
@AllArgsConstructor
public class TokenResponseDto {
    private String accessToken;
    private String refreshToken;
    private boolean pwChangeRecommended; // 90일 경과 시 true → 프론트에서 모달 표시
}

pwChangeRecommended 를 추가한 이유

90일 비밀번호 미변경 사용자에게 변경 권장 모달을 띄워야 한다.

별도 API를 만드는 대신 로그인 응답에 플래그 하나를 포함시켜서 추가 요청 없이 처리했다.


3. RefreshToken.java

📦 domain/auth/entity

@Entity
@Table(name = "REFRESH_TOKENS")
public class RefreshToken {

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Column(name = "token_hash", nullable = false, length = 255)
    private String tokenHash;  // 원문이 아닌 해시값만 저장

    @Column(name = "expires_at", nullable = false)
    private LocalDateTime expiresAt;  // 7일 유효

    public static RefreshToken of(User user, String tokenHash) {
        RefreshToken token = new RefreshToken();
        token.user = user;
        token.tokenHash = tokenHash;
        token.expiresAt = LocalDateTime.now().plusDays(7);
        return token;
    }
}

토큰 원문이 아닌 해시값을 저장하는 이유

DB가 탈취당해도 해시값만으로는 원본 토큰을 복원할 수 없다.

클라이언트에서 보낸 토큰을 해시화한 뒤 DB의 해시값과 비교하는 방식으로 검증한다.

Hard Delete 방식을 선택한 이유

로그아웃·탈퇴 시 토큰을 Soft Delete(deleted_at 컬럼)로 두면

만료된 토큰이 테이블에 계속 쌓인다. 보안 토큰은 무효화 즉시 완전 삭제가 맞다.


4. PasswordResetToken.java

📦 domain/auth/entity

@Entity
@Table(name = "PASSWORD_RESET_TOKENS")
public class PasswordResetToken {

    @Column(nullable = false, unique = true, length = 255)
    private String token;  // UUID 기반 랜덤 토큰

    @Column(name = "expires_at", nullable = false)
    private LocalDateTime expiresAt;  // 30분 유효

    @Column(name = "is_used", nullable = false)
    private boolean isUsed = false;  // 1회 사용 후 재사용 불가

    // 유효한 토큰인지 확인 (만료 + 사용 여부 동시 체크)
    public boolean isValid() {
        return !isUsed && LocalDateTime.now().isBefore(expiresAt);
    }

    // 비밀번호 변경 완료 후 호출
    public void markUsed() {
        this.isUsed = true;
    }
}

isValid() / markUsed() 를 Entity 안에 넣은 이유

유효성 체크 조건(만료 시각 + 사용 여부)이 바뀌어도 이 파일 한 곳만 수정하면 된다.

Service에서 조건을 직접 비교하면 같은 조건이 여러 곳에 흩어질 수 있다.


5. RefreshTokenRepository.java

📦 domain/auth/repository

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByTokenHash(String tokenHash); // 토큰 재발급 시 조회
    void deleteByUserId(Long userId);                         // 로그아웃·탈퇴 시 전체 삭제
}

deleteByUserId 를 쓴 이유

한 유저가 여러 기기에서 로그인하면 Refresh Token이 여러 개 생긴다.

로그아웃·탈퇴 시 userId 기준으로 전부 삭제해야 다른 기기에서도 자동 로그아웃된다.


6. PasswordResetTokenRepository.java

📦 domain/auth/repository

public interface PasswordResetTokenRepository extends JpaRepository<PasswordResetToken, Long> {
    Optional<PasswordResetToken> findByToken(String token); // 이메일 링크의 토큰값으로 조회
}

7. AuthService.java

📦 domain/auth/service

public interface AuthService {
    TokenResponseDto login(LoginRequestDto dto);       // 로그인 → JWT 발급
    TokenResponseDto refresh(String refreshToken);     // 토큰 재발급
    void logout(Long userId);                          // Refresh Token 삭제
    void sendPasswordResetEmail(String email);         // 재설정 이메일 발송
    void resetPassword(String token, String newPassword); // 실제 비밀번호 변경
}

8. AuthServiceImpl.java

📦 domain/auth/service

로그인 흐름 (login)

public TokenResponseDto login(LoginRequestDto dto) {
    // 1. 유저 조회
    User user = userRepository.findByUsername(dto.getUsername())
            .orElseThrow(...);

    // 2. 계정 잠금 확인
    if (user.isLocked()) {
        throw new IllegalStateException("5분 후 다시 시도해주세요.");
    }

    // 3. 비밀번호 불일치 → 실패 횟수 +1 (5회 시 자동 잠금)
    if (!passwordEncoder.matches(dto.getPassword(), user.getPasswordHash())) {
        user.increaseLoginFailCount();
        throw new IllegalArgumentException("아이디 또는 비밀번호가 틀렸습니다.");
    }

    // 4. 성공 → 실패 횟수 초기화
    user.resetLoginFail();

    // 5. 90일 미변경 여부 체크
    boolean pwChangeRecommended = false;
    if (user.getLastPwChangedAt() != null) {
        long days = ChronoUnit.DAYS.between(user.getLastPwChangedAt(), LocalDateTime.now());
        pwChangeRecommended = days >= 90;
    }

    // JWT는 JwtProvider 완성 후 연결 예정 (주석으로 자리 확보)
    return new TokenResponseDto("access-token-placeholder", "refresh-token-placeholder", pwChangeRecommended);
}

비밀번호 재설정 흐름 (sendPasswordResetEmail → resetPassword)

// 1단계: 이메일로 링크 발송
public void sendPasswordResetEmail(String email) {
    // 소셜 전용 계정(passwordHash == null) 차단
    String token = UUID.randomUUID().toString();
    passwordResetTokenRepository.save(PasswordResetToken.of(user, token));
    // TODO: emailService.sendResetEmail(email, token);
}

// 2단계: 링크 클릭 후 실제 변경
public void resetPassword(String token, String newPassword) {
    PasswordResetToken resetToken = passwordResetTokenRepository.findByToken(token)
            .orElseThrow(...);

    if (!resetToken.isValid()) {  // 만료 or 이미 사용된 토큰 차단
        throw new IllegalStateException("만료되었거나 이미 사용된 토큰입니다.");
    }

    user.updatePassword(passwordEncoder.encode(newPassword));
    resetToken.markUsed();  // 재사용 방지

    // 보안 이력 기록
    userSecurityHistoryRepository.save(
        UserSecurityHistory.of(user, SecurityEventType.PW_CHANGE, "비밀번호 재설정 완료")
    );
}

왜 UUID.randomUUID()로 토큰을 만들었나

예측 불가능한 128비트 랜덤값이라 외부에서 추측이 불가능하다.

별도 라이브러리 없이 Java 표준으로 쓸 수 있어서 선택했다.


9. AuthController.java

📦 domain/auth/controller

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

    // POST /api/auth/login
    @PostMapping("/login")
    public ResponseEntity<TokenResponseDto> login(@RequestBody LoginRequestDto dto) { ... }

    // POST /api/auth/refresh
    @PostMapping("/refresh")
    public ResponseEntity<TokenResponseDto> refresh(@RequestBody Map<String, String> body) { ... }

    // POST /api/auth/logout
    @PostMapping("/logout")
    public ResponseEntity<Void> logout(@RequestParam Long userId) { ... }

    // POST /api/auth/password/reset-request
    @PostMapping("/password/reset-request")
    public ResponseEntity<Void> sendResetEmail(@RequestBody Map<String, String> body) { ... }

    // PATCH /api/auth/password/reset
    @PatchMapping("/password/reset")
    public ResponseEntity<Void> resetPassword(@RequestBody Map<String, String> body) { ... }
}

logout에 @RequestParam을 쓴 이유

JWT가 아직 완성되지 않아서 토큰에서 userId를 꺼낼 수가 없었다.

임시로 @RequestParam으로 받고, JwtProvider 완성 후 @AuthenticationPrincipal로 교체할 예정이다.


⚠️ 현재 미완성 부분 (TODO)

항목 현재 상태 완성 조건

JWT 발급 placeholder 문자열 반환 홍은표 JwtProvider 완성 후 연결
이메일 발송 System.out.println으로 출력 EmailService 완성 후 교체
logout userId @RequestParam으로 임시 수신 JWT 완성 후 @AuthenticationPrincipal로 교체

💡 배운 점

Refresh Token은 원문이 아닌 해시값을 DB에 저장해야 탈취 시 피해를 최소화할 수 있다.

비밀번호 재설정 토큰처럼 상태(유효/만료/사용됨)가 있는 경우 isValid(), markUsed() 같은 상태 변경 메서드를 Entity 안에 넣으면 Service가 단순해진다.

JWT 같은 외부 의존이 미완성일 때는 placeholder를 명시적으로 남기고 TODO 주석으로 자리를 잡아두면 나중에 연결하기 쉽다.