Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c97e5c8
build: Gradle의존성 추가(Security, OAuth2, JWT, Swagger)(#2)
JONGTAE02 Nov 24, 2025
8304e91
feat: 유저 엔티티 및 레포지토리 구현 (#2)
JONGTAE02 Nov 24, 2025
0e0b112
feat: JWT 토큰 생성 및 인증 필터 구현(#2)
JONGTAE02 Nov 24, 2025
04c5c87
config: Security 및 Swagger 환경 설정(#2)
JONGTAE02 Nov 24, 2025
daaa550
feat: AuthController 구현 및 깃허브 로그인 URL API 추가(#2)
JONGTAE02 Nov 24, 2025
8ef3a91
feat: OAuth2 유저 서비스 및 로그인 성공 핸들러 구현(#2)
JONGTAE02 Nov 24, 2025
5222c30
chore: 의미없는 주석 해제(#2)
JONGTAE02 Nov 24, 2025
3064898
chore: 의미없는 주석 제거(#2)
JONGTAE02 Nov 24, 2025
15742a5
Merge remote-tracking branch 'origin/feature/login-auth-github_new' i…
JONGTAE02 Nov 24, 2025
3ed823f
refactor: JwtProvider 초기화 로직 개선 및 에러 핸들링 강화(#2)
JONGTAE02 Nov 25, 2025
3008bbf
refactor: JwtProvider 초기화 로직 개선 및 에러 핸들링 강화(#2)
JONGTAE02 Nov 25, 2025
46dd688
feat: complete coding test backend implementation
wonkeun-choi Nov 27, 2025
a38e274
fix: coding test issue
wonkeun-choi Nov 27, 2025
15f142b
Merge remote-tracking branch 'origin/feature/login-auth-github_new' i…
JONGTAE02 Nov 27, 2025
20b292e
Merge remote-tracking branch 'origin/main' into feature/login-auth-gi…
JONGTAE02 Nov 27, 2025
edb40e3
fix: Base64 형식으로 JWT secret key Update(#2)
JONGTAE02 Nov 27, 2025
65bba8a
fix: add docker-compose installation step for CI
wonkeun-choi Nov 28, 2025
5e2d3f6
Merge pull request #8 from skill-boost/feature/login-auth-github_new
byb0823 Nov 28, 2025
ef77138
Merge branch 'main' into develop
JONGTAE02 Nov 28, 2025
b8ef268
feat: add temporary AI code review using Gemini
wonkeun-choi Nov 28, 2025
244f0bc
chore: resolve merge with main
wonkeun-choi Nov 28, 2025
0928c0b
feat: Redis를 이용한 리프레시 토큰 로테이션 핵심 로직 구현(#10)
JONGTAE02 Nov 28, 2025
169ebb9
feat: 인증 API 및 로그인 핸들러에 토큰 로테이션 기능 적용(#10)
JONGTAE02 Nov 28, 2025
1711360
Complete coding test backend
wonkeun-choi Nov 28, 2025
ca36ca5
Complete coding test backend
wonkeun-choi Nov 28, 2025
2a715a5
AI Code Review backend complete
wonkeun-choi Nov 28, 2025
9170e7b
Merge branch 'develop' into feature/backend-implementation
wonkeun-choi Nov 28, 2025
983296a
AI Code Review backend complete
wonkeun-choi Nov 28, 2025
6b26eeb
Merge branch 'feature/backend-implementation' of https://github.com/s…
wonkeun-choi Nov 28, 2025
1c23da0
Merge pull request #12 from skill-boost/feature/backend-implementation
wonkeun-choi Nov 28, 2025
6a20584
Merge pull request #11 from skill-boost/feature/redis-token-management
byb0823 Nov 30, 2025
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,7 @@ out/
.vscode/

.env
*secret.yaml
*secret.yaml

### Secret Config ###
src/main/resources/application-secret.yml
8 changes: 8 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'com.h2database:h2'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'

}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;


@SpringBootApplication
public class SkillBoostApplication {

public static void main(String[] args) {
SpringApplication.run(SkillBoostApplication.class, args);
}

}
}
59 changes: 59 additions & 0 deletions src/main/java/com/example/skillboost/auth/JwtFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.example.skillboost.auth;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";

private final JwtProvider jwtProvider;

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws IOException, ServletException {

// Request Header에서 JWT 토큰 추출
String jwt = resolveToken(request);

// JWT 토큰 유효성 검증
if (StringUtils.hasText(jwt) && jwtProvider.validateToken(jwt)) {
// 유효한 토큰이면 Authentication 객체를 생성하여 SecurityContext에 저장
Authentication authentication = jwtProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("JWT 인증 성공: {}", authentication.getName());
} else if (StringUtils.hasText(jwt)) {
log.warn("유효하지 않은 JWT 토큰");
}

// 다음 필터로 진행
filterChain.doFilter(request, response);
}

/**
* Request Header에서 JWT 토큰 추출
*/
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);

if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(BEARER_PREFIX.length()).trim();
}

return null;
}
}
129 changes: 129 additions & 0 deletions src/main/java/com/example/skillboost/auth/JwtProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package com.example.skillboost.auth;

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Base64;
import java.util.Collections;
import java.util.Date;

@Slf4j
@Component
public class JwtProvider {

private Key key;

@Value("${jwt.secret-key}")
private String secretKeyBase64;

@Value("${jwt.expiration-ms}")
private long accessTokenExpirationMs;

private final long refreshTokenExpirationMs = 14 * 24 * 60 * 60 * 1000L;

@PostConstruct
protected void init() {
log.info("========== JWT 설정 값 확인 ==========");
log.info("입력된 Secret Key: [{}]", this.secretKeyBase64);

if (this.secretKeyBase64 == null || this.secretKeyBase64.startsWith("${")) {
throw new RuntimeException("환경변수 [JWT_SECRET_KEY]가 설정되지 않았습니다! IntelliJ 설정을 확인해주세요.");
}
String safeKey = this.secretKeyBase64.replaceAll("\\s+", "");

try {
byte[] keyBytes = Base64.getDecoder().decode(safeKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
log.info("JWT Provider 정상 초기화 완료");
} catch (IllegalArgumentException e) {
log.error("Base64 디코딩 실패. 키 값을 확인해주세요. (현재 값: {})", safeKey);
throw e;
}
}
/**
* Access Token 생성 (짧은 수명)
*/
public String createAccessToken(String email) {
return createToken(email, accessTokenExpirationMs);
}

/**
* Refresh Token 생성 (긴 수명)
*/
public String createRefreshToken(String email) {
return createToken(email, refreshTokenExpirationMs);
}
/**
* JWT 토큰 생성
*/
public String createToken(String email, long expirationTime) {
Date now = new Date();
Date expiry = new Date(now.getTime() + expirationTime);

return Jwts.builder()
.setSubject(email)
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
/**
* 토큰에서 사용자 ID(Email) 추출
*/
public String getUserId(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}

/**
* JWT 토큰 유효성 검증
*/
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
} catch (ExpiredJwtException e) {
log.error("JWT 토큰이 만료되었습니다: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
log.error("지원되지 않는 JWT 토큰입니다: {}", e.getMessage());
} catch (MalformedJwtException e) {
log.error("잘못된 형식의 JWT 토큰입니다: {}", e.getMessage());
} catch (SecurityException e) {
log.error("JWT 서명이 올바르지 않습니다: {}", e.getMessage());
} catch (IllegalArgumentException e) {
log.error("JWT 토큰이 비어있습니다: {}", e.getMessage());
}
return false;
}

/**
* JWT 토큰에서 Authentication 객체 생성
*/
public Authentication getAuthentication(String token) {
String email = getUserId(token);

User principal = new User(
email,
"",
Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"))
);

return new UsernamePasswordAuthenticationToken(principal, token, principal.getAuthorities());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.example.skillboost.auth.config;

import com.example.skillboost.auth.JwtFilter;
import com.example.skillboost.auth.JwtProvider;
import com.example.skillboost.auth.handler.OAuth2SuccessHandler;
import com.example.skillboost.auth.service.CustomOAuth2UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig {

private final OAuth2SuccessHandler oAuth2SuccessHandler;
private final CustomOAuth2UserService customOAuth2UserService;
private final JwtProvider jwtProvider;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 요청 권한 설정
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/auth/**",
"/oauth2/**",
"/login/oauth2/**",
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs/**",
"/swagger-resources/**",
"/webjars/**",
"/favicon.ico"
).permitAll()
.anyRequest().authenticated()
)
// CSRF 비활성화
.csrf(AbstractHttpConfigurer::disable)
// JWT 필터 적용
.addFilterBefore(new JwtFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)
// 기본 폼 로그인 및 HTTP Basic 인증 비활성화
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
// OAuth2 로그인 설정
.oauth2Login(oauth2 -> oauth2
.successHandler(oAuth2SuccessHandler)
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)
)
);

return http.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.example.skillboost.auth.config;

import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.List;

@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
Server localServer = new Server()
.url("http://localhost:8080")
.description("Local Server");


return new OpenAPI()
.servers(List.of(localServer))
.components(new Components()
.addSecuritySchemes("bearer-token",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")))
.addSecurityItem(new SecurityRequirement().addList("bearer-token"))
.info(new Info()
.title("My Application API")
.description("API Documentation")
.version("1.0.0"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.example.skillboost.auth.controller;

import com.example.skillboost.auth.service.TokenService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@Tag(name = "인증 (Authentication)", description = "로그인, 토큰 재발급, 로그아웃")
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

private final TokenService tokenService;
@Operation(summary = "GitHub 로그인 URL 반환",
description = "프론트엔드에서 이 주소로 GET 요청을 보내면, 사용자가 접속해야 할 GitHub 로그인 페이지 URL을 반환합니다.")
@GetMapping("/github-login-url")
public Map<String, String> getGithubLoginUrl() {
return Map.of("url", "/oauth2/authorization/github");
}


@Operation(summary = "토큰 재발급 (RTR)", description = "Refresh Token을 헤더에 담아 보내면 새로운 Access/Refresh Token을 발급합니다.")
@PostMapping("/reissue")
public ResponseEntity<Map<String, String>> reissue(@RequestHeader("RefreshToken") String refreshToken) {
String token = refreshToken.startsWith("Bearer ") ? refreshToken.substring(7) : refreshToken;
String[] newTokens = tokenService.rotateTokens(token);

return ResponseEntity.ok(Map.of(
"accessToken", newTokens[0],
"refreshToken", newTokens[1]
));
}

@Operation(summary = "로그아웃", description = "Redis에서 Refresh Token을 삭제하여 더 이상 사용할 수 없게 만듭니다.")
@PostMapping("/logout")
public ResponseEntity<String> logout(@RequestHeader("RefreshToken") String refreshToken) {
String token = refreshToken.startsWith("Bearer ") ? refreshToken.substring(7) : refreshToken;

tokenService.logout(token);

return ResponseEntity.ok("로그아웃 되었습니다.");
}
}
Loading