Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ dependencies {
implementation 'io.micrometer:micrometer-core'
implementation 'io.micrometer:micrometer-registry-prometheus'

// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.security:spring-security-test'

// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'com.h2database:h2'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,42 @@
@Getter
@NoArgsConstructor
public class Admin extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "admin_id")
@Column(name = "admin_id", nullable = false)
private Long id;

@Column(name = "admin_company")
private String company;

@Column(name = "admin_account_id")
private String accountId; // ์ •๊ทœํ™” ์ถ”๊ฐ€
@Column(name = "admin_username", nullable = false, unique = true)
private String username;

@Column(name = "admin_password")
@Column(name = "admin_password", nullable = false, length = 100)
private String password;

@Column(name = "admin_name", nullable = false)
private String name;

@Column(name = "admin_contact", nullable = false)
private String contact;

@Builder(access = AccessLevel.PRIVATE)
private Admin(String company, String accountId, String password) {
this.company = company;
this.accountId = accountId;
private Admin(String username, String password, String name, String contact) {
this.username = username;
this.password = password;
this.name = name;
this.contact = contact;
}

public static Admin createAdmin(String username, String password, String name, String contact) {
return Admin.builder()
.username(username)
.password(password)
.name(name)
.contact(contact)
.build();
}

public static Admin createAdmin(String company, String accountId, String password) {
return Admin.builder().company(company).accountId(accountId).password(password).build();
public void updateAdminPassword(String passwordNew) {
this.password = passwordNew;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.doubleo.adminservice.domain.admin.repository;

import com.doubleo.adminservice.domain.admin.domain.Admin;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface AdminRepository extends JpaRepository<Admin, Long> {
Optional<Admin> findAdminByUsername(String username);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.doubleo.adminservice.domain.auth.controller;

import com.doubleo.adminservice.domain.auth.dto.AccessTokenDto;
import com.doubleo.adminservice.domain.auth.dto.RefreshTokenDto;
import com.doubleo.adminservice.domain.auth.dto.request.LoginRequest;
import com.doubleo.adminservice.domain.auth.dto.response.LoginResponse;
import com.doubleo.adminservice.domain.auth.service.AuthService;
import com.doubleo.adminservice.domain.auth.service.JwtTokenService;
import com.doubleo.adminservice.global.util.CookieUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.util.WebUtils;

@Tag(name = "1-2. Auth API", description = "ํšŒ์› ๋กœ๊ทธ์ธ/๋กœ๊ทธ์•„์›ƒ/Refresh Token ๊ด€๋ จ API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/auth")
public class AuthController {

private final AuthService authService;
private final CookieUtil cookieUtil;
private final JwtTokenService jwtTokenService;

@Operation(summary = "ํšŒ์› ๋กœ๊ทธ์ธ", description = "ํšŒ์› ๋กœ๊ทธ์ธ์„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.")
@PostMapping("/login")
public ResponseEntity<LoginResponse> adminLogin(@RequestBody LoginRequest request) {
LoginResponse response = authService.loginAdmin(request);
String refreshToken = response.refreshToken();
HttpHeaders headers = cookieUtil.generateRefreshTokenCookie(refreshToken);

return ResponseEntity.ok().headers(headers).body(response);
}

@Operation(summary = "ํšŒ์› ๋กœ๊ทธ์•„์›ƒ", description = "ํšŒ์› ๋กœ๊ทธ์•„์›ƒ์„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.")
@PostMapping("/logout")
public ResponseEntity<Void> adminLogout(
@RequestHeader(HttpHeaders.AUTHORIZATION) String authorizationHeader,
@RequestHeader("X-Admin-Id") Long adminId,
HttpServletResponse response) {
authService.logoutAdmin(authorizationHeader, adminId);

ResponseCookie clearCookie =
ResponseCookie.from("refreshToken", "")
.httpOnly(true)
.secure(true)
.path("/")
.maxAge(0)
.sameSite("Strict")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, clearCookie.toString());

return ResponseEntity.noContent().build();
}

@Operation(
summary = "Access Token ์žฌ๋ฐœ๊ธ‰",
description = "์œ ํšจํ•œ RefreshToken ์„ ํ†ตํ•ด AccessToken ์„ ์žฌ๋ฐœ๊ธ‰ํ•ฉ๋‹ˆ๋‹ค.")
@PostMapping("/reissue")
public ResponseEntity<Void> tokenReissue(
HttpServletRequest request, HttpServletResponse response) {
String oldAccessToken = extractAccessTokenFromHeader(request);
String refreshToken = extractRefreshTokenFromCookie(request);

RefreshTokenDto refreshTokenDto = jwtTokenService.retrieveRefreshToken(refreshToken);
if (refreshTokenDto == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
AccessTokenDto newAccessTokenDto =
jwtTokenService.reissueAccessTokenIfExpired(oldAccessToken);
response.addHeader(
HttpHeaders.AUTHORIZATION, "Bearer " + newAccessTokenDto.accessTokenValue());

return ResponseEntity.ok().build();
}

private String extractRefreshTokenFromCookie(HttpServletRequest request) {
Cookie cookie = WebUtils.getCookie(request, "refreshToken");
return (cookie != null) ? cookie.getValue() : null;
}

private String extractAccessTokenFromHeader(HttpServletRequest request) {
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
return authorizationHeader.substring(7);
}
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.doubleo.adminservice.domain.auth.domain;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.TimeToLive;

@Getter
@RedisHash("blacklistToken")
public class BlackListToken {

@Id private final String token;

@TimeToLive private final long ttl;

@Builder(access = AccessLevel.PRIVATE)
private BlackListToken(String token, long ttl) {
this.token = token;
this.ttl = ttl;
}

public static BlackListToken createBlackListToken(String token, Long ttl) {
return builder().token(token).ttl(ttl).build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.doubleo.adminservice.domain.auth.domain;

import lombok.Builder;
import lombok.Getter;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.TimeToLive;

@Getter
@RedisHash(value = "refreshToken")
public class RefreshToken {

@Id private final Long adminId;

private final String token;

@TimeToLive private final long ttl;

@Builder
private RefreshToken(Long adminId, String token, long ttl) {
this.adminId = adminId;
this.token = token;
this.ttl = ttl;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.doubleo.adminservice.domain.auth.dto;

public record AccessTokenDto(Long adminId, String accessTokenValue) {
public static AccessTokenDto of(Long adminId, String accessTokenValue) {
return new AccessTokenDto(adminId, accessTokenValue);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.doubleo.adminservice.domain.auth.dto;

public record RefreshTokenDto(Long adminId, String refreshTokenValue, Long ttl) {
public static RefreshTokenDto of(Long adminId, String refreshTokenValue, Long ttl) {
return new RefreshTokenDto(adminId, refreshTokenValue, ttl);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.doubleo.adminservice.domain.auth.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;

public record LoginRequest(
@Schema(description = "๊ด€๋ฆฌ์ž ์•„์ด๋””", example = "username") String username,
@Schema(description = "๊ด€๋ฆฌ์ž ํŒจ์Šค์›Œ๋“œ", example = "pw12345") String password) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.doubleo.adminservice.domain.auth.dto.response;

import com.fasterxml.jackson.annotation.JsonIgnore;

public record LoginResponse(String accessToken, @JsonIgnore String refreshToken) {
public static LoginResponse of(String accessToken, String refreshToken) {
return new LoginResponse(accessToken, refreshToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.doubleo.adminservice.domain.auth.repository;

import com.doubleo.adminservice.domain.auth.domain.BlackListToken;
import org.springframework.data.repository.CrudRepository;

public interface BlackListTokenRepository extends CrudRepository<BlackListToken, String> {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.doubleo.adminservice.domain.auth.repository;

import com.doubleo.adminservice.domain.auth.domain.RefreshToken;
import org.springframework.data.repository.CrudRepository;

public interface RefreshTokenRepository extends CrudRepository<RefreshToken, Long> {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.doubleo.adminservice.domain.auth.service;

import com.doubleo.adminservice.domain.auth.dto.request.LoginRequest;
import com.doubleo.adminservice.domain.auth.dto.response.LoginResponse;

public interface AuthService {

LoginResponse loginAdmin(LoginRequest request);

void logoutAdmin(String accessTokenValue, Long adminId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.doubleo.adminservice.domain.auth.service;

import com.doubleo.adminservice.domain.admin.domain.Admin;
import com.doubleo.adminservice.domain.admin.repository.AdminRepository;
import com.doubleo.adminservice.domain.auth.dto.request.LoginRequest;
import com.doubleo.adminservice.domain.auth.dto.response.LoginResponse;
import com.doubleo.adminservice.domain.auth.repository.RefreshTokenRepository;
import com.doubleo.adminservice.global.exception.CommonException;
import com.doubleo.adminservice.global.exception.errorcode.AdminErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {

private final AdminRepository adminRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final JwtTokenService jwtTokenService;
private final BCryptPasswordEncoder encoder;

public LoginResponse loginAdmin(LoginRequest request) {
Admin admin = validateAdminByEmail(request.username());
if (!encoder.matches(request.password(), admin.getPassword())) {
throw new CommonException(AdminErrorCode.ADMIN_NOT_FOUND);
Copy link

Copilot AI May 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Since an INVALID_PASSWORD error code already exists, consider using it instead of ADMIN_NOT_FOUND to more precisely indicate a password mismatch.

Suggested change
throw new CommonException(AdminErrorCode.ADMIN_NOT_FOUND);
throw new CommonException(AdminErrorCode.INVALID_PASSWORD);

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment: ๋น„๋ฐ€๋ฒˆํ˜ธ&์•„์ด๋”” ์ค‘ ์–ด๋А ๊ฒƒ์ด ์ž˜๋ชป๋˜์—ˆ๋Š”์ง€, ์™ธ๋ถ€ ๊ณต๊ฒฉ์ž์—๊ฒŒ ์ƒ์„ธํžˆ ๋…ธ์ถœํ•˜์ง€ ์•Š๋Š”๊ฒŒ ์ข‹๋‹ค๊ณ  ์ƒ๊ฐํ•ด์„œ ๋กœ๊ทธ์ธ ๋กœ์ง์— ํ•œํ•ด ADMIN_NOT_FOUND ์ฝ”๋“œ๋กœ ํ†ตํ•ฉํ•ด ๊ฑธ์–ด๋‘์—ˆ๋Š”๋ฐ, ๋ฆฌ๋ทฐํ•˜์‹œ๋Š” ๋ถ„๋“ค์ด INVALID_PASSWORD๋กœ ๊ตฌ์ฒดํ™”ํ•˜๋Š” ๊ฒƒ์ด ์ข‹๊ฒ ๋‹ค๋Š” ์˜๊ฒฌ ๋‚ด์‹œ๋ฉด ๋ฐ˜์˜ํ•ด์„œ ์ˆ˜์ •ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ADMIN_NOT_FOUND๋กœ ํ†ตํ•ฉํ•˜๋Š” ๊ฒƒ์ด ์ข‹์„ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค.

}
return getLoginResponse(admin);
}

public void logoutAdmin(String accessTokenValue, Long adminId) {
validateAdminById(adminId);
refreshTokenRepository.deleteById(adminId);
jwtTokenService.putAccessTokenOnBlackList(accessTokenValue);
}

private Admin validateAdminByEmail(String email) {
return adminRepository
.findAdminByUsername(email)
.orElseThrow(() -> new CommonException(AdminErrorCode.ADMIN_NOT_FOUND));
}

private void validateAdminById(Long adminId) {
adminRepository
.findById(adminId)
.orElseThrow(() -> new CommonException(AdminErrorCode.ADMIN_NOT_FOUND));
}

private LoginResponse getLoginResponse(Admin admin) {
String accessToken = jwtTokenService.createAccessToken(admin.getId());
String refreshToken = jwtTokenService.createRefreshToken(admin.getId());
return LoginResponse.of(accessToken, refreshToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.doubleo.adminservice.domain.auth.service;

import com.doubleo.adminservice.domain.auth.dto.AccessTokenDto;
import com.doubleo.adminservice.domain.auth.dto.RefreshTokenDto;

public interface JwtTokenService {

// AccessToken DTO ์ƒ์„ฑ
AccessTokenDto createAccessTokenDto(Long adminId);

// AccessToken ์ƒ์„ฑ
String createAccessToken(Long adminId);

// RefreshToken ์ƒ์„ฑ
String createRefreshToken(Long adminId);

// DB ์ €์žฅ๋œ RefreshToken ์กฐํšŒ ๋ฐ ๊ฒ€์ฆ
RefreshTokenDto retrieveRefreshToken(String refreshTokenValue);

// AccessToken ๋งŒ๋ฃŒ ์—ฌ๋ถ€ ๊ฒ€์ฆ ํ›„ ์žฌ๋ฐœ๊ธ‰
AccessTokenDto reissueAccessTokenIfExpired(String accessTokenValue);

// ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” AccessToken BlackList ๋“ฑ๋ก
void putAccessTokenOnBlackList(String accessTokenValue);
}
Loading