[api] swagger 셋팅중

- swagger login 처리
This commit is contained in:
2026-02-11 17:04:06 +09:00
parent 9bc499ab97
commit 5a59c78d83
17 changed files with 584 additions and 1 deletions
+9
View File
@@ -26,6 +26,15 @@ dependencies {
// Web 기본 // Web 기본
implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-web'
// security
implementation 'org.springframework.boot:spring-boot-starter-security'
// 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'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// Lombok // Lombok
compileOnly 'org.projectlombok:lombok' compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok'
@@ -2,8 +2,10 @@ package com.alist.api;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication @SpringBootApplication
@ConfigurationPropertiesScan
public class ApiApplication { public class ApiApplication {
public static void main(String[] args) { public static void main(String[] args) {
@@ -0,0 +1,34 @@
package com.alist.api.common.response;
import lombok.Getter;
import org.springframework.http.ResponseEntity;
@Getter
public class ApiResponse<T> {
private T data; // 실제 데이터
private String code; // 결과 코드 (SUCCESS, ERROR_001 등)
private String message; // 사용자 메시지
public ApiResponse(T data, String code, String message) {
this.data = data;
this.code = code;
this.message = message;
}
public static ApiResponse<Void> body(ApiResponseCode code, Object... args) {
return new ApiResponse<>(null, code.code(), code.message(args));
}
public static <T> ApiResponse<T> body(T data, ApiResponseCode code, Object... args) {
return new ApiResponse<>(data, code.code(), code.message(args));
}
public static ResponseEntity<ApiResponse<Void>> entity(ApiResponseCode code, Object... args) {
return ResponseEntity.status(code.httpStatus()).body(ApiResponse.body(code, args));
}
public static <T> ResponseEntity<ApiResponse<T>> entity(T data, ApiResponseCode code, Object... args) {
return ResponseEntity.status(code.httpStatus()).body(ApiResponse.body(data, code, args));
}
}
@@ -0,0 +1,53 @@
package com.alist.api.common.response;
import org.springframework.http.HttpStatus;
import java.text.MessageFormat;
public enum ApiResponseCode {
// ===== Common =====
CODE_200 ("200", "성공", HttpStatus.OK),
CODE_400 ("400", "잘못된 요청", HttpStatus.BAD_REQUEST),
CODE_401 ("401", "인증 필요 합니다.", HttpStatus.UNAUTHORIZED),
CODE_403 ("403", "접근 권한 필요 합니다.", HttpStatus.FORBIDDEN),
CODE_404 ("404", "페이지를 찾을 수 없습니다. 입력하신 주소가 올바른지 확인해주세요.", HttpStatus.NOT_FOUND),
CODE_405 ("405", "잘못된 요청입니다. 요청 방식을 확인해 주세요.", HttpStatus.METHOD_NOT_ALLOWED),
CODE_500 ("500", "요청을 처리하는 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR),
// ===== Success detail =====
CODE_2001("2001", "{0} 정보 조회에 성공하였습니다.", HttpStatus.OK),
CODE_2002("2002", "{0} 등록 되었습니다.", HttpStatus.CREATED),
CODE_2003("2003", "조회된 정보가 없습니다.", HttpStatus.OK),
CODE_2004("2004", "중복된 {0} 정보 입니다.", HttpStatus.CONFLICT),
// ===== Client input errors =====
// @Valid / 바인딩 / 타입미스매치 / JSON 파싱 실패 등은 다 여기로
CODE_4001("4001", "입력값을 확인해주세요.", HttpStatus.BAD_REQUEST),
// 필수 요청 파라미터 누락
CODE_4003("4003", "필수 요청 파라미터가 누락되었습니다.", HttpStatus.BAD_REQUEST),
;
private final String code;
private final String message;
private final HttpStatus httpStatus;
ApiResponseCode(String code, String message, HttpStatus httpStatus) {
this.code = code;
this.message = message;
this.httpStatus = httpStatus;
}
public String code() { return code; }
public String message() { return message; }
public String message(Object... args) {
return MessageFormat.format(this.message, args);
}
public HttpStatus httpStatus() {
return httpStatus;
}
}
@@ -0,0 +1,16 @@
package com.alist.api.common.utils;
import java.security.SecureRandom;
import java.util.Base64;
public class ApiKeyGenerator {
private static final SecureRandom secureRandom = new SecureRandom();
private static final Base64.Encoder base64Encoder = Base64.getUrlEncoder().withoutPadding();
public static String userApiKeyProc() {
byte[] randomBytes = new byte[32]; // 256-bit
secureRandom.nextBytes(randomBytes);
return base64Encoder.encodeToString(randomBytes);
}
}
@@ -2,6 +2,11 @@ package com.alist.api.config;
import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Info; import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@Configuration @Configuration
@@ -13,4 +18,20 @@ import org.springframework.context.annotation.Configuration;
) )
) )
public class OpenApiConfig { public class OpenApiConfig {
private static final String SECURITY_SCHEME_NAME = "bearerAuth";
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.addSecurityItem(new SecurityRequirement().addList(SECURITY_SCHEME_NAME))
.components(new Components()
.addSecuritySchemes(SECURITY_SCHEME_NAME,
new SecurityScheme()
.name("Authorization")
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
)
);
}
} }
@@ -0,0 +1,86 @@
package com.alist.api.config;
import com.alist.api.config.jwt.JwtAccessDeniedHandler;
import com.alist.api.config.jwt.JwtAuthenticationEntryPoint;
import com.alist.api.config.jwt.JwtAuthenticationFilter;
import com.alist.api.config.jwt.JwtTokenProvider;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Slf4j
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Value("${swagger.login.id}")
private String swaggerLoginId;
@Value("${swagger.login.password}")
private String swaggerLoginPassword;
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
return new JwtAuthenticationFilter(jwtTokenProvider);
}
@Bean
@Order(1)
public SecurityFilterChain swaggerFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf(csrf -> csrf.disable());
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain apiFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new JwtAuthenticationEntryPoint())
.accessDeniedHandler(new JwtAccessDeniedHandler())
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/actuator/health", "/auth/**", "/api/user/signup").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails alist = User.builder()
.username(swaggerLoginId)
.password(passwordEncoder().encode(swaggerLoginPassword))
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(alist);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@@ -0,0 +1,105 @@
package com.alist.api.config.exception;
import com.alist.api.common.response.ApiResponse;
import com.alist.api.common.response.ApiResponseCode;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.javassist.NotFoundException;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.util.LinkedHashMap;
import java.util.Map;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handleIllegalArgument(IllegalArgumentException e) {
log.warn("IllegalArgumentException: {}", e.getMessage());
return ApiResponse.entity(ApiResponseCode.CODE_400);
}
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ApiResponse<Void>> handleMissingParam(MissingServletRequestParameterException e) {
log.warn("Missing request parameter: {} (type={})", e.getParameterName(), e.getParameterType());
return ApiResponse.entity(ApiResponseCode.CODE_4003);
}
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ApiResponse<Void>> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
log.warn("Method not supported: {} (supported={})", e.getMethod(), e.getSupportedHttpMethods());
return ApiResponse.entity(ApiResponseCode.CODE_405);
}
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNotFound(NotFoundException e) {
log.warn("Not found: {}", e.getMessage());
return ApiResponse.entity(ApiResponseCode.CODE_404);
}
// 입력오류
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleMethodArgumentNotValid(MethodArgumentNotValidException e) {
Map<String, String> fieldErrors = new LinkedHashMap<>();
for (FieldError fe : e.getBindingResult().getFieldErrors()) {
fieldErrors.putIfAbsent(fe.getField(), fe.getDefaultMessage());
}
log.warn("Validation failed: {}", e.getMessage());
return ApiResponse.entity(fieldErrors, ApiResponseCode.CODE_4001);
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiResponse<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
log.warn("Type mismatch: name={}, value={}", e.getName(), e.getValue());
return ApiResponse.entity(ApiResponseCode.CODE_4001);
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiResponse<Void>> handleNotReadable(HttpMessageNotReadableException e) {
log.warn("Unreadable message (json parse?) : {}", e.getMessage());
return ApiResponse.entity(ApiResponseCode.CODE_4001);
}
@ExceptionHandler(BindException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleBindException(BindException e) {
Map<String, String> fieldErrors = new LinkedHashMap<>();
for (FieldError fe : e.getBindingResult().getFieldErrors()) {
fieldErrors.putIfAbsent(fe.getField(), fe.getDefaultMessage());
}
log.warn("Bind failed: {}", e.getMessage());
return ApiResponse.entity(fieldErrors, ApiResponseCode.CODE_4001);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
log.error("Unhandled exception occurred", e);
return ApiResponse.entity(ApiResponseCode.CODE_500);
}
}
@@ -0,0 +1,29 @@
package com.alist.api.config.jwt;
import com.alist.api.common.response.ApiResponse;
import com.alist.api.common.response.ApiResponseCode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import java.io.IOException;
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException e) throws IOException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
ApiResponse<Void> body = ApiResponse.body(ApiResponseCode.CODE_403);
objectMapper.writeValue(response.getOutputStream(), body);
}
}
@@ -0,0 +1,28 @@
package com.alist.api.config.jwt;
import com.alist.api.common.response.ApiResponse;
import com.alist.api.common.response.ApiResponseCode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import java.io.IOException;
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException e) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
ApiResponse<Void> body = ApiResponse.body(ApiResponseCode.CODE_401);
objectMapper.writeValue(response.getOutputStream(), body);
}
}
@@ -0,0 +1,60 @@
package com.alist.api.config.jwt;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
try {
String token = resolveToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
String userId = jwtTokenProvider.getUserId(token);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
SecurityContextHolder.clearContext();
// 로깅은 여기서 해도 됨 (SLF4J)
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearer = request.getHeader(HttpHeaders.AUTHORIZATION);
if (bearer == null) return null;
// "Bearer " 뒤 토큰만 추출
if (bearer.startsWith("Bearer ")) {
String token = bearer.substring(7).trim();
return token.isEmpty() ? null : token;
}
return null;
}
}
@@ -0,0 +1,92 @@
package com.alist.api.config.jwt;
import com.alist.api.config.properties.JwtProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
@Component
public class JwtTokenProvider {
private final JwtProperties jwtProperties;
private final SecretKey secretKey;
public JwtTokenProvider(JwtProperties jwtProperties) {
this.jwtProperties = jwtProperties;
this.secretKey = Keys.hmacShaKeyFor(
jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8)
);
}
/** 토큰생성 **/
public String createToken(String userId) {
Date now = new Date();
Date expiry = new Date(
now.getTime() + jwtProperties.getAccessTokenValiditySeconds() * 1000
);
return Jwts.builder()
.setSubject(userId)
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
/* 엑세스 토큰 생성 */
public String createAccessToken(long userTokenIdx, String role) {
Instant now = Instant.now();
Instant expiry = now.plusSeconds(jwtProperties.getAccessTokenValiditySeconds());
return Jwts.builder()
.setSubject(String.valueOf(userTokenIdx))
.claim("role", role)
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(expiry))
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
/* 리프레시 토큰 생성 */
public String createRefreshToken(long userTokenIdx) {
Instant now = Instant.now();
Instant expiry = now.plusSeconds(jwtProperties.getAccessTokenValiditySeconds());
return Jwts.builder()
.setSubject(String.valueOf(userTokenIdx))
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(expiry))
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
/** 토큰에서 subject 추출 */
public String getUserId(String token) {
return parseClaims(token).getSubject();
}
/** 토큰 검증 */
public boolean validateToken(String token) {
try {
parseClaims(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
private Claims parseClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
}
}
@@ -0,0 +1,14 @@
package com.alist.api.config.properties;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Getter
@Setter
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
String secret;
long accessTokenValiditySeconds;
long refreshTokenValiditySeconds;
}
@@ -0,0 +1,14 @@
package com.alist.api.modules.main;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/")
public class MainController {
@GetMapping(value = "/")
public String index() {
return "redirect:/swagger-ui/index.html";
}
}
+10
View File
@@ -10,6 +10,16 @@ spring:
logging: logging:
config: classpath:logback-local.xml config: classpath:logback-local.xml
jwt:
secret: "F9k3s!29dkF#1lP0X9QZx8eW!2m@0AbD"
access-token-validity-seconds: 3600
refresh-token-validity-seconds: 2592000
swagger:
login:
id: alist
password: "1qaz2wsx!@"
springdoc: springdoc:
api-docs: api-docs:
enabled: true enabled: true
+10
View File
@@ -12,6 +12,16 @@ spring:
logging: logging:
config: classpath:logback-pjt.xml config: classpath:logback-pjt.xml
jwt:
secret: ${JWT_SECRET}
access-token-validity-seconds: 3600
refresh-token-validity-seconds: 2592000
swagger:
login:
id: ${SWAGGER_ID}
password: ${SWAGGER_PASSWORD}
springdoc: springdoc:
api-docs: api-docs:
enabled: true enabled: true
+1 -1
View File
@@ -15,7 +15,7 @@ mybatis:
springdoc: springdoc:
swagger-ui: swagger-ui:
path: /swagger path: /swagger-ui.html
api-docs: api-docs:
path: /v3/api-docs path: /v3/api-docs