AuthContoller.java
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final MemberService memberService;
// 회원가입: 중복 아이디 검증 후 MEMBER 역할로 계정을 생성한다
@PostMapping("/register")
public ResponseEntity<String> register(@RequestBody @Valid RegisterRequest request) {
memberService.register(request);
return ResponseEntity.ok("회원가입 성공");
}
}
memberService의 register를 무사히 통과하면 회원가입 성공을 응답값으로 보낸다.
@RequestBody
- JSON 형식으로 데이터를 받을때 만들어져 있는 객체와 자동으로 매핑시켜 받도록 하는 어노테이션이다.
@Valid
- RegisterRequest 안에 검증조건 어노테이션이 있는데 그 검증조건 어노테이션에 맞게 데이터가 왔는지 검증해주는 어노테이션이다.
RegisterRequest.java 안 검증조건 어노테이션은 이런식으로 돼있다.
// 아이디: 영문·숫자·밑줄만 허용, 4~20자
@NotBlank
@Size(min = 4, max = 20)
@Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "영문, 숫자, 밑줄(_)만 사용 가능합니다.")
private String username;
MemberService.java
// 회원 관련 비즈니스 로직을 처리하는 서비스
// 인증(로그인/로그아웃/토큰 재발급), 프로필 관리, 비밀번호 변경, 회원 탈퇴를 담당한다
@Service
@RequiredArgsConstructor
@Transactional
public class MemberService {
private final MemberMapper memberMapper;
private final PasswordEncoder passwordEncoder;
private final JwtProvider jwtProvider;
private final RefreshTokenMapper refreshTokenMapper;
// Refresh Token 유효기간 (기본값: 7일, 밀리초 단위)
@Value("${jwt.refresh-expiration-ms:604800000}")
private long refreshExpirationMs;
// 회원가입: 중복 아이디 검증 후 BCrypt로 비밀번호를 암호화해 MEMBER 역할로 저장한다
public void register(RegisterRequest request) {
if (memberMapper.findByUsername(request.getUsername()).isPresent()) {
throw new BusinessException(ErrorCode.DUPLICATE_USERNAME);
}
Member member = Member.builder()
.roleId(MemberRole.MEMBER.getRoleId())
.username(request.getUsername())
.name(request.getName())
.password(passwordEncoder.encode(request.getPassword()))
.build();
memberMapper.save(member);
}
}
회원가입 서비스이다.
if (memberMapper.findByUsername(request.getUsername()).isPresent()) {
.isPresent()는 조회한 회원의 ID가 있는지 null체크를 한다.
MemberMapper 인터페이스에 보면
Optional<Member> findByUsername(String username);
이렇게 Optional을 사용해야 Service에서 .isPresent()를 사용할 수 있다.
Optional을 사용한 이유는 null체크를 할 때 코드가 깔끔해지고 잊지않고 null 체크를 할 수 있기 때문이다.
Member member = Member.builder()
.roleId(MemberRole.MEMBER.getRoleId())
.username(request.getUsername())
.name(request.getName())
.password(passwordEncoder.encode(request.getPassword()))
.build();
Member 객체에는 '@Setter'가 없다.
그 이유는 '@Setter'를 사용해 객체에 데이터를 담게 된다면
.set을 사용해 member 객체의 내용물을 중간에 수정할 수 있기 때문에 안정성이 떨어진다.
@Setter 가 없기 때문에 .builder 로 만들어진 member 객체를 더이상 수정할 수 없다
.password(passwordEncoder.encode(request.getPassword()))
에선 비밀번호를 BCrypt로 단방향 암호화해서 객체에 담는다.
생성한 member 객체를 정상적으로 저장하면 회원가입이 완료된다.
MemberMapper.xml
<select id="findByUsername" parameterType="string" resultType="com.aenggukland.letspt.member.Member">
SELECT *
FROM member
WHERE username = #{username}
AND is_deleted = FALSE
</select>
findByUsername의 간단한 쿼리문이다.
탈퇴하지 않은 회원의 정보를 회원아이디를 이용해 조회한다.
<!-- 신규 회원 저장: 가입 시 role_id는 항상 MEMBER(1)로 고정 -->
<insert id="save" parameterType="com.aenggukland.letspt.member.Member">
INSERT INTO member (role_id, username, name, password)
VALUES (#{roleId}, #{username}, #{name}, #{password})
</insert>
member 객체의 데이터(권한, id, 회원명, 비밀번호)를 저장한다.
AuthContoller.java
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final MemberService memberService;
// 운영: true(HTTPS 전용), 로컬: false(HTTP 허용) — application.yml의 cookie.secure 값
@Value("${cookie.secure:false}")
private boolean cookieSecure;
// 로그인: 인증 성공 시 AccessToken을 HttpOnly 쿠키에 담고, 응답 본문으로도 반환한다
// 쿠키 유효기간은 1시간이며 JWT 만료시간(30분)과 별개이다 (TODO B3)
// ResponseCookie를 사용해 SameSite=Strict 와 Secure 속성을 설정한다
@PostMapping("/login")
public ResponseEntity<Map<String, String>> login(@RequestBody @Valid LoginRequest request,
HttpServletResponse response) {
Map<String, String> tokens = memberService.login(request);
// SameSite=Strict: 크로스 사이트 요청 시 쿠키 전송 차단 (CSRF 방어)
// Secure: 운영 환경에서 HTTPS 전용 전송 강제 (cookie.secure=true 시 활성화)
ResponseCookie cookie = ResponseCookie.from("accessToken", tokens.get("accessToken"))
.httpOnly(true)
.path("/")
.maxAge(60 * 60) // 1시간
.secure(cookieSecure)
.sameSite("Strict")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return ResponseEntity.ok(tokens);
}
}
login에선 파라미터에 'HttpServletResponse response'가 있다.
'HttpServletResponse'은 헤더에 데이터를 넣어서 응답값을 리턴해야할 때 사용한다.
파라미터에 써놓으면 Spring이 자동으로 주입해주는 Spring 핵심 기능 중 하나이다.
memberService의 login을 통과하면 cookie 세팅을 한다.
ResponseCookie cookie = ResponseCookie.from("accessToken", tokens.get("accessToken"))
memberService의 login을 통과할때 리턴받은 새 accessToken으로 쿠키 객체를 생성한다.
.httpOnly(true)
JavaScript에서 쿠키를 읽을 수 없도록 차단한다.
XSS 공격으로 document.cookie를 통해 토큰을 탈취하는 것을 방지한다.
.httpOnly(false) 면 JavaScript에서 쿠키를 읽을 수 있다.
.httpOnly(true)를 하는 이유는 JavaScript에서 쿠키를 읽을 수 없게해 XSS 공격을 방지하기 위함이다.
XSS 공격 흐름 :
1. 해커가 악성 스크립트를 사이트에 심음
2. 사용자가 페이지 접근
3. 악성 스크립트가 document.cookie 로 JWT 탈취
4. 해커가 탈취한 JWT로 사용자인 척 활동
여기서 3번을 막음으로써 XSS 공격을 방지할 수 있다.
path()는 쿠키를 어떤 경로에서 사용할지 설정한다.
path("/") 면 모든경로
만약 path("/api/auth") 면:
/api/auth 경로에서만 쿠키 전송,
/api/board 요청할 때는 쿠키를 안 보낸다.
.secure(cookieSecure)은 상단 변수를 사용한다.
// 운영: true(HTTPS 전용), 로컬: false(HTTP 허용) — application.yml의 cookie.secure 값
@Value("${cookie.secure:false}")
private boolean cookieSecure;
${cookie.secure}가 false 일땐 HTTP에서의 요청을 허용하고
${cookie.secure}가 true 일땐 HTTPS의 요청만 허용한다.
그래서 로컬 개발환경에선 false로 놓고 운영에선 true로 수정해야한다.
true 일때 HTTP에서 요청이 온다면
1. 브라우저가 쿠키 안 보냄
2. 서버가 JWT 못 받음
3. 인증 실패 → 401 응답
이렇게 처리된다.
.sameSite("Strict")
는 다른 사이트에서 온 요청은 쿠키를 안보낸다는 의미다.
CSRF 공격 흐름:
1. 사용자가 우리 사이트 로그인 상태
2. 해커의 낚시 사이트 클릭
3. 낚시 사이트가 우리 사이트로 몰래 요청
4. 브라우저가 쿠키 자동으로 담아서 전송 → 공격 성공
에서 3번을 막음으로써 CSRF 공격을 방지할 수 있다.
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
브라우저가 SET_COOKIE 헤더를 보고 쿠키에 자동으로 저장한다. 서버는 명령서만 보낼 뿐 직접 쿠키를 저장하지 않는다.
1. 서버가 SET_COOKIE 헤더에 쿠키 데이터를 담아서 응답
2. 브라우저가 SET_COOKIE 헤더를 보고
"아 이 값을 쿠키에 저장하라는 거구나"
3. 브라우저가 쿠키 저장소에 저장
4. 이후 요청마다 브라우저가 자동으로 쿠키를 같이 보냄
MemberService.java
// 회원 관련 비즈니스 로직을 처리하는 서비스
// 인증(로그인/로그아웃/토큰 재발급), 프로필 관리, 비밀번호 변경, 회원 탈퇴를 담당한다
@Service
@RequiredArgsConstructor
@Transactional
public class MemberService {
private final MemberMapper memberMapper;
private final PasswordEncoder passwordEncoder;
private final JwtProvider jwtProvider;
private final RefreshTokenMapper refreshTokenMapper;
// Refresh Token 유효기간 (기본값: 7일, 밀리초 단위)
@Value("${jwt.refresh-expiration-ms:604800000}")
private long refreshExpirationMs;
// 로그인: 비밀번호 검증 후 AccessToken(JWT)과 RefreshToken(UUID)을 발급한다
// RefreshToken은 DB에 저장되며, 동일 username으로 재로그인 시 덮어쓴다
public Map<String, String> login(LoginRequest request) {
Member member = memberMapper.findByUsername(request.getUsername())
.orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND));
if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) {
throw new BusinessException(ErrorCode.INVALID_PASSWORD);
}
String roleName = MemberRole.fromRoleId(member.getRoleId()).name();
String accessToken = jwtProvider.createToken(member.getUsername(), roleName);
String refreshToken = UUID.randomUUID().toString();
refreshTokenMapper.save(RefreshToken.builder()
.username(member.getUsername())
.token(refreshToken)
.expiresAt(LocalDateTime.now().plusSeconds(refreshExpirationMs / 1000))
.build());
return Map.of("accessToken", accessToken, "refreshToken", refreshToken);
}
}
로그인 서비스이다.
로그인 서비스에선 회원정보가 있는지, 비밀번호가 일치하는지를 판단하고
accessToken과 refreshToken을 새로 생성하는 작업을 한다.
Member member = memberMapper.findByUsername(request.getUsername())
.orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND));에서
파라미터의 회원ID로 회원 정보를 찾고 역시
MemberMapper.java 인터페이스에서 Optional<Member> findByUsername(String username);
Optional을 쓰고 있기 때문에 null처리를 해준다.
if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) {
조회한 member의 비밀번호와 파라미터로 받은 비밀번호를 비교한다.
BCrypt는 단방향 암호화라 복호화가 불가능하다.
대신 입력받은 비밀번호를 DB에 저장된 해시값의 salt를 이용해 동일하게 암호화한 후 비교한다.
이러한 방식으로 요청 비밀번호를 재암호화해서 DB의 비밀번호와 비교한 후 같지 않으면 BusinessException을 떨어뜨린다.
String roleName = MemberRole.fromRoleId(member.getRoleId()).name();
조회한 member의 roleId로 Enum의 권한을 담는다.
String accessToken = jwtProvider.createToken(member.getUsername(), roleName);
jwtProvider에서 사용자ID, 권한을 이용해 accessToken을 만든다.
String refreshToken = UUID.randomUUID().toString();
UUID 방식으로 refreshToken을 만든다.
refreshTokenMapper.save(RefreshToken.builder()
.username(member.getUsername())
.token(refreshToken)
.expiresAt(LocalDateTime.now().plusSeconds(refreshExpirationMs / 1000))
.build());
refreshToken테이블에 회원ID, 위에서 만든 refreshToken, 만료기간을 저장한다.
return Map.of("accessToken", accessToken, "refreshToken", refreshToken);
새로 만든 accessToken과 refreshToken을 리턴한다.
RefreshTokenMapper.xml
<insert id="save" parameterType="com.aenggukland.letspt.security.RefreshToken">
INSERT INTO refresh_token (username, token, expires_at)
VALUES (#{username}, #{token}, #{expiresAt})
ON CONFLICT (username)
DO UPDATE SET token = EXCLUDED.token,
expires_at = EXCLUDED.expires_at,
created_at = CURRENT_TIMESTAMP
</insert>
새로 생성된 refreshToken을 저장하는 쿼리문이다.
ON CONFLICT (username)
DO UPDATE SET token = EXCLUDED.token,
expires_at = EXCLUDED.expires_at,
created_at = CURRENT_TIMESTAMP
이부분은 username이 UNIQUE 설정이 돼있어 충돌이 날 수가 있는데
충돌이 났을 경우 파라미터로 넘어온 새 refreshToken과 만료일을 저장하도록 해놨다.
AuthContoller.java
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final MemberService memberService;
// 운영: true(HTTPS 전용), 로컬: false(HTTP 허용) — application.yml의 cookie.secure 값
@Value("${cookie.secure:false}")
private boolean cookieSecure;
// Access Token 재발급: 유효한 Refresh Token을 검증하고 새 Access Token을 반환한다
@PostMapping("/refresh")
public ResponseEntity<Map<String, String>> refresh(@RequestBody Map<String, String> body) {
String newAccessToken = memberService.refresh(body.get("refreshToken"));
return ResponseEntity.ok(Map.of("accessToken", newAccessToken));
}
}
memberService의 refresh를 통과하면 새로운 accessToken을 응답값으로 내려준다.
MemberService.java
@Service
@RequiredArgsConstructor
@Transactional
public class MemberService {
private final MemberMapper memberMapper;
private final PasswordEncoder passwordEncoder;
private final JwtProvider jwtProvider;
private final RefreshTokenMapper refreshTokenMapper;
// Refresh Token 유효기간 (기본값: 7일, 밀리초 단위)
@Value("${jwt.refresh-expiration-ms:604800000}")
private long refreshExpirationMs;
// Access Token 재발급: Refresh Token 유효성(존재 여부, 만료 여부)을 검증하고 새 토큰을 반환한다
// 만료된 경우 DB에서 토큰을 삭제한 뒤 예외를 던진다
public String refresh(String refreshToken) {
RefreshToken rt = refreshTokenMapper.findByToken(refreshToken)
.orElseThrow(() -> new BusinessException(ErrorCode.INVALID_TOKEN));
if (rt.getExpiresAt().isBefore(LocalDateTime.now())) {
refreshTokenMapper.deleteByUsername(rt.getUsername()); // 만료된 토큰 즉시 삭제
throw new BusinessException(ErrorCode.EXPIRED_TOKEN);
}
Member member = memberMapper.findByUsername(rt.getUsername())
.orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND));
String roleName = MemberRole.fromRoleId(member.getRoleId()).name();
return jwtProvider.createToken(rt.getUsername(), roleName);
}
}
refreshTokenMapper에서 refreshToken값으로 저장된 refreshToken을 조회한다.
null이면 INVALID_TOKEN 에러코드 반환
현재 날짜와 조회한 refreshToken의 만료 날짜와 비교하고 만료날짜가 지나면
refreshToken을 제거한 후 EXPIRED_TOKEN 에러코드를 반환한다.
에러코드를 반환받으면 새로 로그인을 해서 새로운 refreshToken을 발급받아야 한다.
조회한 refreshToken의 유저ID로 사용자 정보를 조회한 후
조회한 사용자 정보의 권한과 유저ID로 새로운 accessToken을 발급 받는다.
그래서 두가지 개선점이 있다.
1. httpOnly 쿠키 삭제 안 함
로그아웃해도 브라우저 쿠키에 AccessToken 남아있음
서버에서 maxAge=0 으로 덮어씌워야 함
2. AccessToken 블랙리스트 없음
탈취된 AccessToken이 30분간 유효
Redis로 블랙리스트 만들면 즉시 차단 가능
AuthContoller.java
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final MemberService memberService;
// 로그아웃: Refresh Token을 DB에서 삭제해 재사용을 방지한다
@PostMapping("/logout")
public ResponseEntity<Void> logout(@RequestBody Map<String, String> body) {
memberService.logout(body.get("refreshToken"));
return ResponseEntity.ok().build();
}
}
memberService의 logout를 무사히 통과하면 로그아웃 성공을 응답값으로 보낸다.
MemberService.java
@Service
@RequiredArgsConstructor
@Transactional
public class MemberService {
private final MemberMapper memberMapper;
private final PasswordEncoder passwordEncoder;
private final JwtProvider jwtProvider;
private final RefreshTokenMapper refreshTokenMapper;
// 로그아웃: DB에서 Refresh Token을 삭제해 재사용을 방지한다
public void logout(String refreshToken) {
RefreshToken rt = refreshTokenMapper.findByToken(refreshToken)
.orElseThrow(() -> new BusinessException(ErrorCode.INVALID_TOKEN));
refreshTokenMapper.deleteByUsername(rt.getUsername());
}
}
refreshTokenMapper에서 파라미터로 받은 refreshToken으로 refreshToken을 조회한 후
refreshTokenMapper에서 조회한 refreshToken의 username으로 refreshToken을 제거한다.