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
11 changes: 11 additions & 0 deletions src/main/java/com/example/umc9th/auth/annotation/AuthUser.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.example.umc9th.auth.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthUser {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.example.umc9th.auth.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckBlacklist {
}
42 changes: 42 additions & 0 deletions src/main/java/com/example/umc9th/auth/aspect/BlacklistAspect.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.example.umc9th.auth.aspect;

import com.myApp.global.apiPayload.code.status.AuthErrorCode;
import com.myApp.global.apiPayload.exception.GeneralException;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class BlacklistAspect {

private final StringRedisTemplate redisTemplate;

@Before("@annotation(com.myApp.auth.annotation.CheckBlacklist)")
public void checkBlacklist() {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())
.getRequest();
String bearerToken = request.getHeader("Authorization");

if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
String accessToken = bearerToken.substring(7);

// Redis에 BlackList로 저장되어 있는지 확인
String isLogout = redisTemplate.opsForValue().get("blacklist:" + accessToken);

if (StringUtils.hasText(isLogout)) {
throw new GeneralException(AuthErrorCode.AUTH_TOKEN_INVALID);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.example.umc9th.auth.controller;

import com.myApp.auth.dto.TokenDto;
import com.myApp.auth.service.AuthService;
import com.myApp.global.apiPayload.ApiResponse;
import com.myApp.global.apiPayload.code.status.GeneralSuccessCode;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;

import org.springframework.http.ResponseCookie;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController implements AuthControllerDocs {

private final AuthService authService;

@PostMapping("/reissue")
public ApiResponse<String> reissue(@CookieValue("refresh_token") String refreshToken,
HttpServletResponse response) {

TokenDto tokenDto = authService.reissue(refreshToken);

// Refresh Token Cookie 설정
ResponseCookie cookie = authService.createRefreshTokenCookie(tokenDto.getRefreshToken());
response.addHeader("Set-Cookie", cookie.toString());

String accessToken = tokenDto.getAccessToken();
response.setHeader("Authorization", "Bearer " + accessToken);

return ApiResponse.onSuccess(GeneralSuccessCode._OK, accessToken);
}

@PostMapping("/logout")
public ApiResponse<String> logout(@RequestHeader("Authorization") String accessToken,
@CookieValue("refresh_token") String refreshToken,
HttpServletResponse response) {

authService.logout(accessToken, refreshToken);

// 쿠키 삭제 (빈 값으로 덮어쓰기)
ResponseCookie cookie = ResponseCookie.from("refresh_token", "")
.httpOnly(true)
.secure(true)
.path("/")
.maxAge(0) // 만료
.sameSite("None")
.build();
response.addHeader("Set-Cookie", cookie.toString());

return ApiResponse.onSuccess(GeneralSuccessCode._OK, "로그아웃 되었습니다.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.example.umc9th.auth.controller;

import com.myApp.auth.dto.TokenDto;
import com.myApp.global.apiPayload.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.RequestHeader;

@Tag(name = "Auth", description = "인증 관련 API")
public interface AuthControllerDocs {

@Operation(summary = "토큰 재발급", description = "Cookie에 있는 Refresh Token을 이용하여 새로운 Access Token을 발급합니다.")
ApiResponse<String> reissue(
@Parameter(description = "Refresh Token (HttpOnly Cookie)", required = true) @CookieValue("refresh_token") String refreshToken,
HttpServletResponse response);

@Operation(summary = "로그아웃", description = "사용자를 로그아웃 처리하고 Refresh Token Cookie를 삭제합니다.")
ApiResponse<String> logout(
@Parameter(description = "Access Token", required = true) @RequestHeader("Authorization") String accessToken,
@Parameter(description = "Refresh Token (HttpOnly Cookie)", required = true) @CookieValue("refresh_token") String refreshToken,
HttpServletResponse response);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package com.example.umc9th.auth.controller;

import com.myApp.auth.dto.TokenDto;
import com.myApp.auth.jwt.JwtTokenProvider;
import com.myApp.auth.redis.RefreshToken;
import com.myApp.auth.repository.RefreshTokenRepository;
import com.myApp.auth.entity.Member;
import com.myApp.auth.repository.MemberRepository;
import com.myApp.auth.entity.Role;
import com.myApp.global.apiPayload.ApiResponse;
import com.myApp.global.apiPayload.code.status.GeneralSuccessCode;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Profile;
import org.springframework.http.ResponseCookie;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.Collections;

@RestController
@RequestMapping("/api/v1/auth/test")
@RequiredArgsConstructor
@Profile("dev")
public class AuthTestController {

private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
private final MemberRepository memberRepository;

@Operation(summary = "Dev용 로그인 (토큰 발급)", description = "개발 환경에서 OAuth2 로그인 없이 토큰을 발급받습니다.")
@GetMapping("/login")
public ApiResponse<TokenDto> devLogin(@RequestParam String email, HttpServletResponse response) {
// 1. 사용자 확인 및 강제 생성 (테스트 편의성)
Member member = memberRepository.findByEmail(email)
.orElseGet(() -> memberRepository.save(Member.builder()
.email(email)
.name("Dev User")
.role(Role.USER)
.socialType("DEV")
.socialId("dev_" + email)
.build()));

// 2. Authentication 객체 생성
Authentication authentication = new UsernamePasswordAuthenticationToken(
member.getEmail(),
null,
Collections.singleton(new SimpleGrantedAuthority(member.getRoleKey())));

// 3. 토큰 생성
TokenDto tokenDto = jwtTokenProvider.generateTokenDto(authentication);

// 4. Refresh Token 저장
RefreshToken refreshToken = RefreshToken.builder()
.id(member.getEmail())
.token(tokenDto.getRefreshToken())
.build();
refreshTokenRepository.save(refreshToken);

// 5. 쿠키 설정
ResponseCookie cookie = ResponseCookie.from("refresh_token", tokenDto.getRefreshToken())
.httpOnly(true)
.secure(false) // Dev 환경이므로 false
.path("/")
.maxAge(60 * 60 * 24 * 7) // 7일
.sameSite("None")
.build();
response.addHeader("Set-Cookie", cookie.toString());

return ApiResponse.onSuccess(GeneralSuccessCode._OK, tokenDto);
}
}
17 changes: 17 additions & 0 deletions src/main/java/com/example/umc9th/auth/dto/TokenDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.example.umc9th.auth.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TokenDto {
private String grantType;
private String accessToken;
private String refreshToken;
private Long accessTokenExpiresIn;
}
46 changes: 46 additions & 0 deletions src/main/java/com/example/umc9th/auth/entity/Member.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.example.umc9th.auth.entity;

import com.myApp.global.common.BaseEntity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "users")
public class Member extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private String name;

@Column(nullable = false, unique = true)
private String email;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;

@Column(nullable = false)
private String socialId; // 소셜 로그인 제공자에서 주는 ID

@Column(nullable = false)
private String socialType; // google, kakao, naver

public Member update(String name) {
this.name = name;
return this;
}

public String getRoleKey() {
return this.role.getKey();
}
}
13 changes: 13 additions & 0 deletions src/main/java/com/example/umc9th/auth/entity/Role.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.example.umc9th.auth.entity;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum Role {
USER("ROLE_USER"),
ADMIN("ROLE_ADMIN");

private final String key;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.example.umc9th.auth.handler;

import com.myApp.auth.annotation.AuthUser;
import com.myApp.global.apiPayload.code.status.AuthErrorCode;
import com.myApp.global.apiPayload.exception.GeneralException;
import org.springframework.core.MethodParameter;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

@Component
public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver {

@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthUser.class);
}

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

// 인증 정보가 없거나 익명 사용자인 경우 예외 발생
if (authentication == null || authentication instanceof AnonymousAuthenticationToken
|| !authentication.isAuthenticated()) {
throw new GeneralException(AuthErrorCode.UNAUTHORIZED);
}

Object principal = authentication.getPrincipal();

// Principal이 UserDetails 타입인지 확인
if (!(principal instanceof UserDetails)) {
throw new GeneralException(AuthErrorCode.UNAUTHORIZED);
}

return principal;
}
}
Loading
Loading