Skip to content
Open
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
7 changes: 7 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,19 @@ repositories {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jdbc-test'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testCompileOnly 'org.projectlombok:lombok'
testRuntimeOnly 'com.h2database:h2'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testAnnotationProcessor 'org.projectlombok:lombok'
}
Expand Down
73 changes: 73 additions & 0 deletions src/main/java/com/example/auth/controller/AuthController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.example.auth.controller;

import com.example.auth.dto.request.SignInRequest;
import com.example.auth.dto.request.RefreshRequest;
import com.example.auth.dto.request.SignOutRequest;
import com.example.auth.dto.response.OauthGoogleCallbackResponse;
import com.example.auth.dto.response.RefreshResponse;
import com.example.auth.dto.response.SignInResponse;
import com.example.auth.global.dto.ApiResponse;
import com.example.auth.global.util.ResponseUtil;
import com.example.auth.service.AuthService;
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/auth")
public class AuthController {

private final AuthService authService;

public AuthController(AuthService authService) {
this.authService = authService;
}

@PostMapping("/signin")
public ResponseEntity<ApiResponse<SignInResponse>> signin(@RequestBody SignInRequest request) {
// 로그인 성공 시 Access Token과 Refresh Token을 클라이언트에게 내려줍니다.
SignInResponse response = authService.login(request);
return ResponseEntity.ok(ResponseUtil.success("로그인에 성공했습니다.", response));
}

@GetMapping("/oauth/google")
public ResponseEntity<Void> googleOauth(@RequestParam(required = false) String state) {
// Google 로그인 페이지로 302 Redirect합니다.
URI redirectUri = authService.createGoogleAuthorizationUri(state);
return ResponseEntity.status(HttpStatus.FOUND)
.location(redirectUri)
.build();
}

@GetMapping("/oauth/google/callback")
public ResponseEntity<ApiResponse<OauthGoogleCallbackResponse>> googleOauthCallback(
@RequestParam String code,
@RequestParam(required = false) String state
) {
// Google이 내려준 code로 사용자 정보를 확인한 뒤 JWT를 발급합니다.
OauthGoogleCallbackResponse response =
authService.loginWithGoogle(code, state);
return ResponseEntity.ok(ResponseUtil.success("Google 로그인에 성공했습니다.", response));
}

@PostMapping("/refresh")
public ResponseEntity<ApiResponse<RefreshResponse>> refresh(@RequestBody RefreshRequest request) {
// Refresh Token이 유효하면 새로운 토큰 묶음을 발급합니다.
RefreshResponse response = authService.refresh(request);
return ResponseEntity.ok(ResponseUtil.success("토큰 재발급에 성공했습니다.", response));
}

@DeleteMapping("/signout")
public ResponseEntity<ApiResponse<Void>> signout(@RequestBody SignOutRequest request) {
// 로그아웃은 클라이언트가 가진 Refresh Token을 저장소에서 제거하는 방식입니다.
authService.logout(request);
return ResponseEntity.ok(ResponseUtil.success("로그아웃에 성공했습니다."));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.example.auth.dto.request;

public record RefreshRequest(
String refreshToken
) {
}
7 changes: 7 additions & 0 deletions src/main/java/com/example/auth/dto/request/SignInRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.example.auth.dto.request;

public record SignInRequest(
String email,
String password
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.example.auth.dto.request;

public record SignOutRequest(
String refresh_token
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.auth.dto.response;

public record OauthGoogleCallbackResponse(
String name,
String role,
String access_token,
String refresh_token,
Number expires_in
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.example.auth.dto.response;

public record RefreshResponse(
String access_token,
String refresh_token,
Number expires_in
) {
}
10 changes: 10 additions & 0 deletions src/main/java/com/example/auth/dto/response/SignInResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.auth.dto.response;

public record SignInResponse(
String name,
String role,
String access_token,
String refresh_token,
String expires_in
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.example.auth.global.client;

import com.example.auth.global.client.dto.response.GoogleTokenResponse;
import com.example.auth.global.client.dto.response.GoogleUserInfoResponse;
import java.net.URI;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.UriComponentsBuilder;

@Component
public class GoogleOauthClient {

private final RestClient restClient;
private final String clientId;
private final String clientSecret;
private final String redirectUri;
private final String authorizationUri;
private final String tokenUri;
private final String userInfoUri;
private final String scope;

public GoogleOauthClient(
@Value("${oauth.google.client-id}") String clientId,
@Value("${oauth.google.client-secret}") String clientSecret,
@Value("${oauth.google.redirect-uri}") String redirectUri,
@Value("${oauth.google.authorization-uri}") String authorizationUri,
@Value("${oauth.google.token-uri}") String tokenUri,
@Value("${oauth.google.user-info-uri}") String userInfoUri,
@Value("${oauth.google.scope}") String scope
) {
this.restClient = RestClient.create();
this.clientId = clientId;
this.clientSecret = clientSecret;
this.redirectUri = redirectUri;
this.authorizationUri = authorizationUri;
this.tokenUri = tokenUri;
this.userInfoUri = userInfoUri;
this.scope = scope;
}

public URI createAuthorizationUri(String state) {
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(authorizationUri)
.queryParam("client_id", clientId)
.queryParam("redirect_uri", redirectUri)
.queryParam("response_type", "code")
.queryParam("scope", scope);

if (StringUtils.hasText(state)) {
builder.queryParam("state", state);
}

return builder.build()
.encode()
.toUri();
}

public GoogleTokenResponse requestToken(String code) {
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("code", code);
body.add("client_id", clientId);
body.add("client_secret", clientSecret);
body.add("redirect_uri", redirectUri);
body.add("grant_type", "authorization_code");

return restClient.post()
.uri(tokenUri)
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(body)
.retrieve()
.body(GoogleTokenResponse.class);
}

public GoogleUserInfoResponse requestUserInfo(String accessToken) {
return restClient.get()
.uri(userInfoUri)
.headers(headers -> headers.setBearerAuth(accessToken))
.retrieve()
.body(GoogleUserInfoResponse.class);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.example.auth.global.client;

import com.example.auth.dto.request.SignInRequest;
import com.example.auth.global.client.dto.response.GoogleUserInfoResponse;
import com.example.auth.global.client.dto.response.UserAuthResponse;
import com.example.auth.global.client.dto.request.UserGoogleOauthRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

@Component
public class UserServiceClient {

private final RestClient restClient;

public UserServiceClient(
@Value("${user-service.base-url:http://localhost:8081}") String userServiceBaseUrl //임시
) {
this.restClient = RestClient.builder()
.baseUrl(userServiceBaseUrl)
.build();
}

public UserAuthResponse authenticate(SignInRequest request) {
return restClient.post()
.uri("/internal/users/authenticate")
.body(request)
.retrieve()
.body(UserAuthResponse.class);
}

public UserAuthResponse authenticateGoogle(GoogleUserInfoResponse request) {
UserGoogleOauthRequest userRequest = new UserGoogleOauthRequest(
request.providerId(),
request.email(),
request.emailVerified(),
request.name(),
request.picture()
);

return restClient.post()
.uri("/internal/users/oauth/google")
.body(userRequest)
.retrieve()
.body(UserAuthResponse.class);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.auth.global.client.dto.request;

public record UserGoogleOauthRequest(
String providerId,
String email,
Boolean emailVerified,
String name,
String picture
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.example.auth.global.client.dto.response;

import com.fasterxml.jackson.annotation.JsonProperty;

public record GoogleTokenResponse(
@JsonProperty("access_token")
String accessToken,

@JsonProperty("token_type")
String tokenType,

@JsonProperty("expires_in")
Long expiresIn,

@JsonProperty("scope")
String scope,

@JsonProperty("id_token")
String idToken
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.example.auth.global.client.dto.response;

import com.fasterxml.jackson.annotation.JsonProperty;

public record GoogleUserInfoResponse(
@JsonProperty("sub")
String providerId,

String email,

@JsonProperty("email_verified")
Boolean emailVerified,

String name,

String picture
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example.auth.global.client.dto.response;

import java.util.UUID;

public record UserAuthResponse(
UUID userId,
String name,
String role
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.example.auth.global.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class PasswordEncoderConfig {

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
41 changes: 41 additions & 0 deletions src/main/java/com/example/auth/global/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.example.auth.global.config;

import com.example.auth.global.security.JwtAuthenticationFilter;
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.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
public class SecurityConfig {

private final JwtAuthenticationFilter jwtAuthenticationFilter;

public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
// JWT 인증은 세션을 사용하지 않으므로 CSRF와 세션 생성 X
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 로그인과 토큰 재발급 API는 인증 없이 접근할 수 있게 열어둠
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/auth/signin",
"/auth/refresh",
"/auth/oauth/google",
"/auth/oauth/google/callback"
).permitAll()
.anyRequest().authenticated()
)
// UsernamePasswordAuthenticationFilter 전에 JWT 필터를 실행해 요청 인증을 먼저 처리
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
Loading