OAuth2
Spring Security의 OAuth2 모듈은 OAuth 2.0 프로토콜을 사용하여 사용자 인증 및 권한 부여를 처리하는 기능을 제공합니다. 이를 통해 애플리케이션은 Facebook, Google, GitHub 등과 같은 외부 OAuth 2.0 제공자를 사용하여 사용자를 인증할 수 있습니다.
Spring Security OAuth2 기능
- OAuth2 로그인: 외부 OAuth 2.0 제공자를 통해 사용자를 인증합니다.
- Resource Server: 액세스 토큰을 검증하고 보호된 리소스에 대한 접근을 제어합니다.
- Authorization Server: 인증과 권한 부여를 처리하고 액세스 토큰을 발급합니다.
클라이언트 정보얻기
구글: https://console.cloud.google.com/home/dashboard
네이버: https://developers.naver.com/products/login/api/api.md
카카오: https://developers.kakao.com/
프로젝트 설정
의존성 추가
// oauth2
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
OAuth2 클라이언트 설정을 추가
- resources/config/security.yml
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: profile, email
redirect-uri: http://localhost:8080/login/oauth2/code/google # 인증 후 사용자가 리디렉션될 URI
naver:
client-id: ${NAVER_CLIENT_ID}
client-secret: ${NAVER_CLIENT_SECRET}
client-authentication-method: client_secret_post # post가 아닌 client_secret_post로 설정해줘야함!
authorization-grant-type: authorization_code
redirect-uri: http://localhost:8080/login/oauth2/code/naver
scope: profile
client-name: Naver
kakao:
client-id: ${KAKAO_CLIENT_ID}
client-secret: ${KAKAO_CLIENT_SECRET}
redirect-uri: http://localhost:8080/login/oauth2/code/kakao
client-authentication-method: client_secret_post
authorization-grant-type: authorization_code
scope: profile_nickname, profile_image, account_email
client-name: Kakao
provider: # OAuth 2.0 제공자의 엔드포인트를 정의
google:
authorization-uri: https://accounts.google.com/o/oauth2/auth # 사용자를 인증하기 위한 URI
token-uri: https://oauth2.googleapis.com/token # 액세스 토큰을 발급받기 위한 URI
user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo # 사용자 정보를 가져오기 위한 URI
user-name-attribute: sub # 사용자 정보에서 사용자 이름으로 사용할 속성
naver:
authorization-uri: https://nid.naver.com/oauth2.0/authorize
token-uri: https://nid.naver.com/oauth2.0/token
user-info-uri: https://openapi.naver.com/v1/nid/me
user-name-attribute: response
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token_uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user_name_attribute: id
- application.yml
spring:
application:
name: project-api
config:
import:
- config/database.yml
- config/security.yml
코드 구현
SecurityConfig - OAuth2 로그인 흐름 설정
- oauth2Login(): OAuth2 로그인 기능을 활성화합니다.
- userInfoEndpoint: OAuth2 로그인 과정에서 사용자 정보를 가져오는 엔드포인트를 설정합니다. 이 설정을 통해 OAuth2 제공자(Google, Facebook 등)로부터 사용자 정보를 가져올 때 사용됩니다.
- userService(customOAuth2UserService): OAuth2 로그인 후 사용자 정보를 처리하는 엔드포인트로 커스텀 서비스 customOAuth2UserService를 설정하여 사용자 정보를 로드하고, 필요에 따라 데이터베이스에 저장하거나 업데이트하는 로직을 구현합니다.
- successHandler(customAuthenticationSuccessHandler): OAuth2 로그인 성공 후 수행할 동작을 정의합니다. 저는 로그인 성공 후 JWT 토큰을 생성하고 이를 클라이언트에게 반환하도록 구현했습니다.
- userInfoEndpoint: OAuth2 로그인 과정에서 사용자 정보를 가져오는 엔드포인트를 설정합니다. 이 설정을 통해 OAuth2 제공자(Google, Facebook 등)로부터 사용자 정보를 가져올 때 사용됩니다.
@Configuration // Spring 설정 클래스임
@EnableWebSecurity // Spring Security를 활성화
public class SecurityConfig {
@Autowired
private CustomOAuth2UserService customOAuth2UserService;
@Autowired
private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;
// HTTP 보안 설정을 구성하는 메서드
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.oauth2Login(oauth2 -> oauth2 // OAuth2 로그인 기능 활성화
.userInfoEndpoint(userInfo -> userInfo // 사용자 정보 엔드포인트 설정
.userService(customOAuth2UserService)) // 사용자 정보 로드,저장,업데이트
.successHandler(customAuthenticationSuccessHandler)) // OAuth2 로그인 성공 후 수행 동작 정의
.cors(cors -> cors.disable()) // cors 비활성화: 외부에서 해당 애플리케이션 리소스에 접근할 수 있게 해주는 보안 기능
.csrf(csrf -> csrf.disable());
return http.build(); // HTTP 보안 설정을 빌드하여 반환
}
}
CustomOAuth2UserService - 사용자 데이터 저장
OAuth2 제공자로부터 사용자 정보를 가져오는 OAuth2 클라이언트 라이브러리의 기본 구현체 DefaultOAuth2UserService를 상속받아 CustomOAuth2UserService를 구현했습니다.
기본 DefaultOAuth2UserService을 사용하면 SecurityConfig에서도 위와 같은 추가 설정 없이 .oauth2Login()만 활성화 해주면 Spring Security는 기본적으로 DefaultOAuth2UserService를 사용하여 OAuth2 로그인 과정을 처리합니다.
커스터마이징의 필요성
DefaultOAuth2UserService를 그대로 사용할 경우, OAuth2 제공자로부터 사용자 정보를 가져오는 기본적인 기능만 수행합니다.
때문에 아래의 기능을 추가해야 한다면 OAuth2UserService를 커스텀 해주어야 합니다.
- 사용자 정보를 데이터베이스에 저장하거나 업데이트해야 할 때
- 사용자 정보를 로드한 후 추가적인 비즈니스 로직을 수행해야 할 때
- OAuth2 제공자로부터 사용자 정보를 특정 방식으로 파싱해야 할 때
- JWT 토큰을 생성하거나 사용자 정보와 함께 반환해야 할 때
❓✋🏻❓질!문!있!어!요!
CustomOAuth2UserService의 loadUser에서 사용자 정보를 가져와서 데이터베이스에 저장할 때 비밀번호는 어떻게 저장하나요?
OAuth2를 통해 로그인한 사용자의 경우, 비밀번호를 입력받지 않기 때문에 데이터베이스에 비밀번호를 저장하지 않습니다. 대신, OAuth2 인증 제공자가 사용자의 신원을 보증하므로 비밀번호는 필요하지 않습니다.
그러나 사용자 계정을 데이터베이스에 저장할 때는 사용자 정보를 저장하고 관리하기 위한 다른 방법을 사용합니다. 예를 들어, 사용자 이메일, 이름, OAuth2 제공자 ID 등을 저장할 수 있습니다.
❓✋🏻❓질!문!있!어!요!
구글 oauth2를 이용한 회원가입까지는 진행했습니다. oauth2를 이용해 회원가입한 사용자는 DB에 패스워드가 없는데 어떻게 로그인할 수 있나요?
OAuth2를 이용해 회원가입한 사용자는 애플리케이션 자체에서 비밀번호를 사용하지 않고, OAuth2 제공자(Google 등)를 통해 인증을 처리합니다. 이 방식에서는 애플리케이션에서 직접 비밀번호를 요구하지 않고, OAuth2 제공자를 통해 사용자의 신원을 확인합니다. 이런 방식으로 가입한 사용자는 OAuth2 로그인 플로우를 통해서만 로그인이 가능합니다.
~ OAuth2 로그인 프로세스 링크 추후 추가 예정 ~
저는 사용자 정보를 기존 user테이블에 넣어주고 로그인 시 jwt 토큰을 반환해주기 위해 커스텀 하여 구현했습니다.
@Slf4j
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
@Autowired
UserMapper userMapper;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Autowired
private CustomUserDetailsService customUserDetailsService;
// OAuth2 로그인 후 사용자 정보를 로드
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
// 구글 OAuth2 제공자 ID 가져오기
String oauth2Provider = userRequest.getClientRegistration().getRegistrationId();
String oauth2ProviderId = oAuth2User.getName();
// 사용자 정보를 가져와서 데이터베이스에 저장
String email = oAuth2User.getAttribute("email");
String name = oAuth2User.getAttribute("name");
// 사용자 정보 저장 또는 업데이트
UserDto.UserBase user = userMapper.findByEmail(UserDto.UserSearchByEmailCondition.builder()
.email(email).build());
if (user == null) {
UserDto.SocialSignUp dto = UserDto.SocialSignUp.builder()
.username(name)
.email(email)
.oauth2Provider(oauth2Provider)
.oauth2ProviderId(oauth2ProviderId)
.build();
userMapper.socialSignUp(dto);
} else {
// 기존 사용자 업데이트
}
// JWT 토큰 생성
CustomUserDetails userDetails = customUserDetailsService.loadUserByUsername(email);
String jwtToken = jwtTokenUtil.generateToken(userDetails);
// JWT 토큰을 사용자 정보에 추가
return new CustomOAuth2User(oAuth2User, jwtToken);
}
}
CustomOAuth2User - OAuth2User 객체와 JWT 토큰을 포함하는 커스텀 객체
OAuth2 인증 후에 발행된 JWT 토큰을 사용자 속성에 포함시키기 위해 CustomOAuth2User 를 구현했습니다.
CustomOAuth2UserService에서 생성한 Jwt토큰을 사용자 정보와 함께 넣어서 Response body에 반환해 주기 위함입니다.
public class CustomOAuth2User implements OAuth2User {
private final OAuth2User oAuth2User;
private final String jwtToken;
public CustomOAuth2User(OAuth2User oAuth2User, String jwtToken) {
this.oAuth2User = oAuth2User;
this.jwtToken = jwtToken;
}
// 기존 OAuth2User의 속성에 JWT 토큰을 추가하여 반환
@Override
public Map<String, Object> getAttributes() {
// 기존 OAuth2User의 속성을 가져와서 새로운 HashMap에 복사
Map<String, Object> attributes = new java.util.HashMap<>(oAuth2User.getAttributes());
// JWT 토큰을 추가
attributes.put("jwtToken", jwtToken);
return attributes;
}
// 사용자의 권한을 반환
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 기존 OAuth2User의 권한을 반환
return oAuth2User.getAuthorities();
}
// 사용자의 이름을 반환
@Override
public String getName() {
return oAuth2User.getName();
}
// JWT 토큰을 반환
public String getJwtToken() {
return jwtToken;
}
}
DTO, Mapper - oauth2 기반 회원가입
@NoArgsConstructor
@Data
public class UserDto {
@Data
public static class UserSocialBase {
private String id;
private String username;
private String email;
private boolean enabled;
private String role;
private String oauth2Provider;
private String oauth2ProviderId;
private String createdAt;
private String updatedAt;
}
@Data
public static class SocialSignUp extends UserSocialBase {
@Builder
public SocialSignUp(String id,
String username,
String email,
boolean enabled,
String role,
String oauth2Provider,
String oauth2ProviderId,
String createdAt,
String updatedAt) {
setId(id);
setUsername(username);
setEmail(email);
setEnabled(enabled);
setRole(role);
setOauth2Provider(oauth2Provider);
setOauth2ProviderId(oauth2ProviderId);
setCreatedAt(createdAt);
setUpdatedAt(updatedAt);
}
}
}
@Mapper
public interface UserMapper {
// oauth2 소셜 로그인 기반 회원가입
int socialSignUp(UserDto.SocialSignUp param);
}
<insert id="socialSignUp"
parameterType="com.tistory.project_api.dto.UserDto$SocialSignUp">
<selectKey keyProperty="id" resultType="string" order="BEFORE">
SELECT CONCAT('U', DATE_FORMAT(NOW(), '%y%m%d'),
LPAD(CAST(COALESCE(MAX(CAST(SUBSTRING(id, 8) AS UNSIGNED)), 0) + 1 AS CHAR), 6, '0')) AS id
FROM user
WHERE SUBSTRING(id, 2, 6) = DATE_FORMAT(NOW(), '%y%m%d');
</selectKey>
INSERT INTO USER (id, username, email, enabled, role, oauth2_provider, oauth2_provider_id, created_at, updated_at)
VALUES (#{id}, #{username}, #{email}, true, 'user', #{oauth2Provider}, #{oauth2ProviderId}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
</insert>
🤔 Next Step에서 고려해야 할 것들과 TODO
CustomOAuth2UserService에서 동일한 이메일로 여러 OAuth2 제공자를 통해 로그인 하는 것을 어떻게 처리할지?
이미 가입된 구글provider 사용자가 같은 이메일로 네이버provider 가입을 하려고 하면 어떻게 처리해야 할지에 대한 고민을 해봐야 할 것 같다. 두가지 정도의 방법이 있을 것 같다.
1. 기존 계정과 연결하여 계정 통합 → OAuth2 제공자 정보 테이블을 추가하여 user테이블과 연결해 관리.
2. 가입 실패 처리 → 다른 제공자로 가입된 이메일이라는 에러 메세지를 반환 처리.
사용자 경험을 중시하는 애플리케이션은 계정 통합을, 보안을 중시하는 애플리케이션은 가입 실패 처리를 선택하는 것이 일반적이라고 한다.
TODO : user_oauth_providers테이블 만들어서 user 테이블과 연결하기
Enum 사용하기
Role, Provider와 같이 정형화된 값들은 Enum으로 관리하도록 수정하려한다.
가독성이나 유지보수성 측면에서도 그렇지만 컴파일 시점에 타입 체크가 가능하여, 잘못된 값이 사용되는 것을 방지하여 오타나 일관성 문제를 해결하여 안정성을 높일 수 있기 때문이다.
TODO : Role, Provider 값 Enum으로 관리하기
Persistence Layer 구조 변경 고민
이전 프로젝트에서의 영향으로 데이터 객체를 DTO로만 사용하고 있다.
DTO를 도메인처럼 사용하고, Response/Request 클래스를 따로 만들어 사용하고 있는 각 객체가 맞지 않게 사용되고 있는 상황이다.
사실 DTO는 계층간 전송에 사용하는건데 이걸 DB와 직접적인 연결까지 하고 있으니 추후 프로젝트가 복잡해지면 비즈니스 로직이 분산되어 응집도가 낮아지고, 코드 중복과 유지보수 문제가 발생할 수 있기 때문에 미리 DTO와 Domain 객체를 분리하여 사용하는 습관을 기르려 한다.
TODO : DTO를 Domain으로 변경하고, Request/Response를 DTO로 변경하기
MyBatis와 JPA 하이브리드 사용
현재 MyBatis를 통해 개발하고 있지만, MyBatis와 JPA를 함께 사용하여 각각의 장점을 활용하려한다.
JPA를 많이 사용하는 추세인 것 같긴한데 SQL쿼리를 직접 작성해보는 것도 놓칠 수 없으니..!
JPA를 붙이면 Entity를 추가해야 하는데 이에 따른 설계도 결정해야한다.
1. 엔티티 객체만 사용하여 하나의 객체로 접근하도록 설계할 것인지 → 동일한 객체를 사용하여 코드 단순해짐. 응집도,성능,의존도 이슈
2. 도메인 객체와 엔티티 객체를 분리하여 설계할 것인지 → 코드 복잡해짐. 유연성, 모듈성 향상
복잡하지 않은 현재 프로젝트 단계에선 하나의 객체로 사용하는 것이 효율적이라고는 하지만,
실무 프로젝트는 더 복잡할테니 복잡하더라도 엔티티,도메인 객체 분리를 사용하여 구현 해봐야겠다.
TODO : JPA 연결, Entity 객체 DB 매핑 확인하기
'Web' 카테고리의 다른 글
[springboot] 설정 클래스 알아보기 (0) | 2024.06.18 |
---|---|
[springboot] 10가지 응답처리 방식 (동기, 비동기 및 스트리밍 응답) (0) | 2024.06.11 |
[springboot] Spring Security 6.x로 로그인 구현하기 (jwt 인증) (0) | 2024.06.05 |
[springboot] Spring Security 6.x로 안전한 회원가입 구현하기 (security 연결, 패스워드 인코딩) (0) | 2024.06.04 |
[springboot] 로그 설정1 (slf4j, logback) - 가장 쉽게 mybatis 쿼리 출력하기 (1) | 2024.06.03 |