-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[27] keycloak + oauth 2.0 적용 및 회원 가입 수정 #31
Changes from 5 commits
f3be8be
095e967
f772d4c
bf22498
f85c2ea
754a2f7
a2df6d1
27c8427
f212dde
366259e
e5dbf2e
d328ab5
235102f
b8d105e
f5b8eff
5046642
090fca1
06ad7f7
f6423ca
cbd79bc
05c9351
a2109d3
aeba881
e2b89db
5a046d6
715d3c5
bbe87ed
f84161f
557276f
311b514
b76602e
843c825
83c5304
2c1e546
95e79b7
cfaee49
93967ce
7791a0e
3eb927e
5db9901
bce1590
bd02af3
eb80d6d
cd554bd
aea5d94
8ac7cd0
503dc1a
a3faf06
018ba66
f99f9cf
753602b
9de69af
3b0b63b
8e9eb4b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
package org.example.commerce_site.application.auth; | ||
|
||
import org.example.commerce_site.application.auth.dto.OAuthAccessTokenResponse; | ||
import org.springframework.beans.factory.annotation.Value; | ||
import org.springframework.http.HttpEntity; | ||
import org.springframework.http.HttpHeaders; | ||
import org.springframework.http.MediaType; | ||
import org.springframework.stereotype.Service; | ||
import org.springframework.util.LinkedMultiValueMap; | ||
import org.springframework.util.MultiValueMap; | ||
import org.springframework.web.client.RestTemplate; | ||
|
||
import lombok.RequiredArgsConstructor; | ||
|
||
@Service | ||
@RequiredArgsConstructor | ||
public class KeycloakAuthService { | ||
private final RestTemplate restTemplate = new RestTemplate(); | ||
@Value("${oauth.keycloak.grant-type}") | ||
private String GRANT_TYPE; | ||
@Value("${oauth.keycloak.credentials.client}") | ||
private String CLIENT_ID; | ||
@Value("${oauth.keycloak.credentials.secret}") | ||
private String CLIENT_SECRET; | ||
@Value("${oauth.keycloak.uri.redirect}") | ||
private String REDIRECT_URI; | ||
@Value("${oauth.keycloak.uri.token}") | ||
private String TOKEN_URI; | ||
|
||
public OAuthAccessTokenResponse.Keycloak getAccessToken(String code) { | ||
MultiValueMap<String, String> info = new LinkedMultiValueMap<>(); | ||
info.add("grant_type", GRANT_TYPE); | ||
info.add("client_id", CLIENT_ID); | ||
info.add("client_secret", CLIENT_SECRET); | ||
info.add("redirect_uri", REDIRECT_URI); | ||
info.add("code", code); | ||
|
||
final HttpHeaders headers = new HttpHeaders(); | ||
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); | ||
|
||
final HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(info, headers); | ||
|
||
return restTemplate.postForEntity(TOKEN_URI, httpEntity, OAuthAccessTokenResponse.Keycloak.class).getBody(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. http 요청을 하기위해서 map을 활용했는데, dto로 클래스를 만드는 것과 map을 사용하는 것에는 어떤 차이가 있을까요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. DTO로 만들면 타입 안정성들을 체크할 수 있을것같습니다. 맵을 사용하면 RestTemplate의 postForEntity 메서드는 MultiValueMap 형식의 요청 본문을 자동으로 URL 인코딩하여 전송합니다. |
||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
package org.example.commerce_site.application.auth.dto; | ||
|
||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; | ||
import com.fasterxml.jackson.annotation.JsonProperty; | ||
|
||
import lombok.Getter; | ||
import lombok.NoArgsConstructor; | ||
import lombok.Setter; | ||
|
||
public class OAuthAccessTokenResponse { | ||
@Getter | ||
@Setter | ||
@NoArgsConstructor | ||
@JsonIgnoreProperties(ignoreUnknown = true) | ||
public static class Keycloak { | ||
@JsonProperty("access_token") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. snake_case를 지원하기 위해 모든 필드에 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 기존에 이미 yml 에 sping.jackson.property-naming-strategy: SNAKE_CASE 를 지정했는데 키클락에서 보내는 요청에 대해서는 어째선지 설정이 제대로 되지 않는것같아서 해당 repsonse 클래스에@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) 를 설정하는 방식으로 수정했습니다! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 키클락에 보내는 요청에 대해서는 왜 설정이 제대로 되지 않는걸까요? |
||
private String accessToken; | ||
|
||
@JsonProperty("expires_in") | ||
private int expiresIn; | ||
|
||
@JsonProperty("refresh_expires_in") | ||
private int refreshExpiresIn; | ||
|
||
@JsonProperty("refresh_token") | ||
private String refreshToken; | ||
|
||
@JsonProperty("token_type") | ||
private String tokenType; | ||
|
||
@JsonProperty("id_token") | ||
private String idToken; | ||
|
||
@JsonProperty("not-before-policy") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이것만 kebab-case인데 맞나요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 수정했습니다! |
||
private int notBeforePolicy; | ||
|
||
@JsonProperty("session_state") | ||
private String sessionState; | ||
|
||
@JsonProperty("scope") | ||
private String scope; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -12,15 +12,15 @@ public class UserRequestDto { | |
@Builder | ||
@ToString | ||
public static class Create { | ||
private String id; | ||
private String name; | ||
private String email; | ||
private String password; | ||
|
||
public static User toEntity(UserRequestDto.Create dto) { | ||
public static User toEntity(UserRequestDto.Create dto, String authId) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 사용하는 곳을 보니 dto.getId()를 하는데 따로 받는 이유가 있나요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 추후 사용자 탈퇴 처리 등을 할때 키클락에 등록된 아이디로 유저를 찾아 삭제하기 위해 저장해뒀습니다 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 아 제 얘기는 이 함수를 호출하는 곳에서, UserRequestDto.Create.toEntity(dto, dto.getId()) 위와 같은 형태로 처리를 하고 있는데, UserRequestDto.Create만 받는게 아니라, authId를 따로 받는 이유가 궁금해서 남긴 리뷰였습니다. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 헉 dto.getId() 를 하면 되는 것인데 실수했습니다; |
||
return User.builder() | ||
.authId(authId) | ||
.name(dto.getName()) | ||
.email(dto.getEmail()) | ||
.password(dto.getPassword()) | ||
.status(UserStatus.ACTIVE) | ||
.build(); | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
package org.example.commerce_site.config; | ||
|
||
import org.keycloak.OAuth2Constants; | ||
import org.keycloak.admin.client.Keycloak; | ||
import org.keycloak.admin.client.KeycloakBuilder; | ||
import org.springframework.beans.factory.annotation.Value; | ||
import org.springframework.context.annotation.Bean; | ||
import org.springframework.context.annotation.Configuration; | ||
|
||
@Configuration | ||
public class KeycloakConfig { | ||
@Value("${oauth.keycloak.realm}") | ||
private String REALM; | ||
|
||
@Value("${oauth.keycloak.auth-server-url}") | ||
private String AUTH_SERVER_URL; | ||
|
||
@Value("${oauth.keycloak.credentials.client}") | ||
private String CLIENT; | ||
|
||
@Value("${oauth.keycloak.credentials.secret}") | ||
private String CLIENT_SECRET; | ||
|
||
@Bean | ||
public Keycloak keycloak() { | ||
return KeycloakBuilder.builder() | ||
.serverUrl(AUTH_SERVER_URL) | ||
.realm(REALM) | ||
.grantType(OAuth2Constants.CLIENT_CREDENTIALS) | ||
.clientId(CLIENT) | ||
.clientSecret(CLIENT_SECRET) | ||
.build(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
package org.example.commerce_site.config; | ||
|
||
import org.springframework.context.annotation.Bean; | ||
import org.springframework.context.annotation.Configuration; | ||
import org.springframework.http.HttpMethod; | ||
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.annotation.web.configurers.AbstractHttpConfigurer; | ||
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; | ||
import org.springframework.security.core.session.SessionRegistryImpl; | ||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; | ||
import org.springframework.security.web.SecurityFilterChain; | ||
import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy; | ||
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; | ||
|
||
@Configuration | ||
@EnableWebSecurity | ||
public class SecurityConfig { | ||
private static final String[] AUTH_EXCLUDE_POST_API_LIST = {"/user/keycloak/webhook"}; | ||
private static final String[] AUTH_EXCLUDE_GET_API_LIST = {"/auth/**"}; | ||
private static final String[] AUTH_EXCLUDE_WEB_LIST = {"/swagger-ui/**", "/api-docs/**"}; | ||
|
||
@Bean | ||
protected SessionAuthenticationStrategy sessionAuthenticationStrategy() { | ||
return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl()); | ||
} | ||
|
||
@Bean | ||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { | ||
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); | ||
|
||
http | ||
.csrf(AbstractHttpConfigurer::disable) | ||
.cors(Customizer.withDefaults()) | ||
.authorizeHttpRequests(requests -> requests | ||
.requestMatchers(HttpMethod.POST, AUTH_EXCLUDE_POST_API_LIST).permitAll() | ||
.requestMatchers(HttpMethod.GET, AUTH_EXCLUDE_GET_API_LIST).permitAll() | ||
.requestMatchers(AUTH_EXCLUDE_WEB_LIST).permitAll() | ||
.anyRequest().authenticated() | ||
) | ||
.oauth2ResourceServer( | ||
oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter))) | ||
.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)); | ||
|
||
return http.build(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,12 @@ | ||
package org.example.commerce_site.infrastructure.user; | ||
|
||
import java.util.Optional; | ||
|
||
import org.example.commerce_site.domain.User; | ||
import org.springframework.data.jpa.repository.JpaRepository; | ||
import org.springframework.stereotype.Repository; | ||
|
||
@Repository | ||
public interface UserRepository extends JpaRepository<User, Long> { | ||
Optional<User> findByEmail(String email); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
package org.example.commerce_site.representation.auth; | ||
|
||
import org.example.commerce_site.application.auth.KeycloakAuthService; | ||
import org.example.commerce_site.application.auth.dto.OAuthAccessTokenResponse; | ||
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 lombok.RequiredArgsConstructor; | ||
import lombok.extern.slf4j.Slf4j; | ||
|
||
@Slf4j | ||
@RestController | ||
@RequiredArgsConstructor | ||
@RequestMapping("/auth") | ||
public class AuthController { | ||
private final KeycloakAuthService keycloakAuthService; | ||
|
||
@GetMapping("/callback") | ||
public OAuthAccessTokenResponse.Keycloak auth(@RequestParam String code) { | ||
return keycloakAuthService.getAccessToken(code); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,15 +1,12 @@ | ||
package org.example.commerce_site.representation.user; | ||
|
||
import org.example.commerce_site.application.user.UserService; | ||
import org.example.commerce_site.common.response.ApiSuccessResponse; | ||
import org.example.commerce_site.representation.user.request.UserRequest; | ||
import org.example.commerce_site.representation.user.response.UserResponse; | ||
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.RestController; | ||
|
||
import jakarta.validation.Valid; | ||
import lombok.RequiredArgsConstructor; | ||
import lombok.extern.slf4j.Slf4j; | ||
|
||
|
@@ -20,9 +17,8 @@ | |
public class UserController { | ||
private final UserService userService; | ||
|
||
@PostMapping() | ||
public ApiSuccessResponse<UserResponse.Create> createUser(@Valid @RequestBody UserRequest.Create request) { | ||
return ApiSuccessResponse.success( | ||
UserResponse.Create.of(userService.create(UserRequest.Create.toDTO(request)))); | ||
@PostMapping("/keycloak/webhook") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이 주소가 외부에 노출되면 어떻게 될까요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 헉.. 생각해보지 못했습니다.. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 네, 해당 방법도 하나의 좋은 방법이 될 것 같습니다. 추가적으로 더 조치할 방법은 없는지 좀 더 찾아보면 좋을 것 같습니다. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
이렇게 두가지 방식을 더 찾아봤는데 1번을 사용하도록 수정했습니다! |
||
public void createUser(@RequestBody UserRequest.Create request) { | ||
userService.create(UserRequest.Create.toDTO(request)); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
ALTER TABLE ecommerce_site.users DROP COLUMN password; | ||
ALTER TABLE ecommerce_site.users ADD auth_id varchar(255) NOT NULL; | ||
ALTER TABLE ecommerce_site.users CHANGE auth_id auth_id varchar(255) NOT NULL AFTER id; | ||
ALTER TABLE ecommerce_site.users ADD CONSTRAINT users_unique UNIQUE KEY (auth_id); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Value
를 활용해서 각각의 필드를 받았는데, 이걸 좀 더 쉽게 한 번에 받는 방법은 없을까요?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
KeycloakProperties 클래스를 만들어 value 로 가져올 수 있는 것들을 @ConfigurationProperties(prefix = "oauth.keycloak") 어노테이션 사용해 이쪽에서 가져오도록 설정했습니다!