1. 서론
신한DS SW Academy에서 2차 프로젝트를 진행하며 구현했던 인증 및 로그인 파트를 리뷰해보고자 한다. Spring security는 뜯어볼수록 굉장히 치밀하고 정교하게 만들어진 Framework였기에, 처음 공부할 때 그 방대함에 조금은 위축됐었던 것 같다. 다소 부족하지만 서비스 제공을 상상하며 열심히 구현했던, 프로젝트의 해당하는 부분을 샅샅이 톺아보자.
2. Authentication/Authorization Flow
자체 플랫폼에 대한 로그인의 경우, 해당 API 서버에서 DB를 통해 사용자를 검증한 후 자체 Access, Refresh Token을 발급한다. 소셜 로그인의 경우, 첫 로그인 요청 시 사용자는 구글 서버로 리다이렉트되고(Google 서버로부터 사용자 정보에 대한 접근 권한을 인가받기 위함.) 그 후 발급받은 code로 Google에서 제공하는 Access Token을 얻는다. 이 Token으로 API 서버는 Google 서버에 해당하는 유저 정보를 요청하게 되고, 유저 정보를 전달받은 API 서버는 이를 통해 자체 Access, Refresh Token을 발급한다. 2~7번과 같은 인가(Authorization) 절차는 Spring security가 자체적으로 처리해주기 때문에, 이를 사용하는 개발자는 Spring boot 환경 파일만 적절히 설정해주어도 복잡한 인가절차를 Spring security framework에게 위임하여 처리할 수 있다.
3. 패키지 구조
auth
|- AuthorizedUser.java
|- HttpCookieOAuth2AuthorizationRequestRepository.java
|- MemberProvider.java
|- MemberRole.java
|- OAuth2SuccessHandler.java
|- SecurityConfig.java
|- - controller
| |- AuthorizingController.java
|- - dto
| |- LoginRequestDTO.java
| |- OAuth2Profile.java
| |- OAuthNewRequestDTO.java
| |- SimpleUserInfo.java
| |- TokenDTO.java
|- - jwt
| |- JwtAuthenticationFilter.java
| |- JwtCookieProvider.java
| |- JwtTokenProvider.java
| |- PasswordEncoderConfig.java
|- - service
| |- UrcarcherOAuth2Service.java
| |- VanillaAuthorizingService.java
entity
|- Member.java
repository
|- MemberRepository.java
4. application.yml
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
...
}
먼저 Spring security와 OAuth 2.0 프로토콜을 사용하기 위해 build.gradle에 위와 같은 dependencies를 추가해야 한다.
spring:
...
security:
oauth2:
client:
registration:
google:
client-id: ...
client-secret: ...
scope:
- email
- profile
Google cloud console(https://console.cloud.google.com/)에서 프로젝트를 만든 뒤, API 및 서비스에서 OAuth 2.0 클라이언트를 생성할 수 있는데, 이 클라이언트의 설정과 Spring boot 환경 설정 파일인 application.yml의 설정을 잘 대응시켜야 OAuth 프로토콜이 정상적으로 작동한다.(Google cloud console에서 OAuth 동의 화면 따로 설정 필요 - 쉬움!)
구글 OAuth 클라이언트가 할당받는 클라이언트 ID와 보안 비밀번호를 각각 yml 파일의 client-id와 client-secret에 작성해야한다. 또한 위 코드 블록에는 생략돼 있지만 google: 부분에 redirect-uri를 따로 설정할 수 있는데, 명시하지 않을 경우 Spring security는 자동으로 https://{your-domain}/login/oauth2/code/google를 redirect uri로 설정한다. 따라서 위 yml 파일처럼 redirect-uri를 생략할 경우, Google OAuth 클라이언트의 설정을 다음과 같이 맞춰주면 된다.
따라서 상단 Flow의 2번 과정에서 구글 로그인에 성공하게 되면, 승인된 리디렉션 uri를 통해 Google 서버가 Spring boot 서버에 code 값을 전달하고, Spring boot 서버는 이 code 값을 가지고 Google의 Access Token을 요청할 수 있게 되는 것이다. 이와 같이 4~7번 과정에서도 token-uri와 resource-uri를 통해 값을 주고 받으며, 이 또한 yml 파일에 명시하지 않아도 Spring security가 해당하는 uri 값을 이미 가지고 있다. scope의 경우 인증된 유저 정보를 전달 받을 때 어떤 정보를 받을 것인지 정하기 위해 쓰인다.
필자는 개발과 배포를 위한 도메인을 각각 따로 발급받았기 때문에 리디렉션 uri가 두 개이고, 둘 다 SSL 인증서를 받아 https 프로토콜을 사용했다.
5. Entity / Repository
Member.java
package com.urcarcher.be.blkwntr.entity;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.hibernate.annotations.CreationTimestamp;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import com.urcarcher.be.blkwntr.auth.MemberProvider;
import com.urcarcher.be.blkwntr.auth.MemberRole;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Entity
@Table(name="MEMBER")
@Builder
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Member {
@Id
private String memberId;
private String password;
private String name;
private String email;
private String dateOfBirth;
private String registrationNumber;
private String gender;
private String nationality;
private String passportNumber;
private String phoneNumber;
private String locationConsent;
private String informationConsent;
private String matchingConsent;
private Integer point;
private String picture;
@CreationTimestamp
private Timestamp registrationDate;
@Enumerated(EnumType.STRING)
private MemberRole role;
@Enumerated(EnumType.STRING)
private MemberProvider provider;
}
해당 서비스는 영속화를 위한 JPA(with MariaDB)를 사용하였다. role과 provider Column을 짚고 넘어가자면, role의 경우 Spring security에서 제시하는 권한을 사용하기 위한 Column이다. provider는 서비스에 대해 다수의 소셜 로그인이 존재하는 경우를 고려하여 플랫폼의 출처로 유저를 구분하기 위해 존재하는 Column이다. 이 두 Column은 모두 Enum Class로 type-safety한 특성을 가질 수 있도록 설정하였다.(런타임 에러 전 컴파일러 차원에서 체크 가능, 코드 가독성 향상)
MemberProvider.java
package com.urcarcher.be.blkwntr.auth;
public enum MemberProvider {
URCARCHER, GOOGLE
}
MemberRole.java
package com.urcarcher.be.blkwntr.auth;
public enum MemberRole {
GUEST, USER
}
MemberRepository.java
package com.urcarcher.be.blkwntr.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import com.urcarcher.be.blkwntr.auth.MemberProvider;
import com.urcarcher.be.blkwntr.entity.Member;
public interface MemberRepository extends JpaRepository<Member, String> {
Optional<Member> findByEmailAndProvider(String email, MemberProvider provider);
Optional<PointMapping> findPointByMemberId(String memberId);
}
6. Spring Security Object
Spring security에는 Authentication interface가 존재한다. 해당 interface의 구현체인 Authentication 객체에는 사용자의 인증 정보를 담고 있는 객체를 저장할 수 있는데, 이 사용자의 정보를 가지고 있는 객체 타입이 UserDetails와 OAuth2User이다. 두 타입의 차이점은 다음과 같은데, 소셜 로그인이 아닌 방식으로 접근한 사용자에 대한 정보는 UserDetails 타입에 저장이 되고, 소셜 로그인 방식으로 접근한 사용자는 OAuth2User 타입을 가지게 된다. 로그인 시 Authentication 객체에는 사용자의 정보가 담기고, 또 이 Authentication 객체는 SecurityContext 객체에 담기게 된다. 따라서 API Server에서는 SecurityContext내에 존재하는 사용자의 정보로 현재 접근한 사용자에 대한 정보를 조회할 수 있다.
본 서비스는 JWT 방식으로 사용자를 검증하므로, 첫 로그인시 Access Token 및 Refresh Token을 발급한다(Cookie에 저장). 이후 Spring security는 하나의 요청마다 Cookie에 저장된 Access Token을 가져와 복호화한 후, Token에 담겨 있는 사용자의 정보를 Authentication 객체에 담고 이를 새로 생성된 SecurityContext 객체에 저장하여 사용자의 정보를 제공한다. 이러한 응답 후 SecurityContext 객체는 소멸된다.
그렇다면 사용자의 정보를 담는 UserDetails와 OAuth2User에 대한 구현체가 필요한데, 로그인 방식에 따라 처리 로직을 두가지로 분리시켜 구현하는 것은 코드상 번거롭고 직관적이지 않다. 따라서 두 interface를 동시에 implements하는 class를 구현하였다.
AuthorizedUser.java
package com.urcarcher.be.blkwntr.auth;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import com.urcarcher.be.blkwntr.entity.Member;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AuthorizedUser implements UserDetails, OAuth2User {
private static final long serialVersionUID = 1L;
private static final String ROLE_PREFIX = "ROLE_";
private Member member;
private Map<String, Object> attributes;
private String attributeName;
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<SimpleGrantedAuthority> simpleGrantedAuthority = new ArrayList<>();
simpleGrantedAuthority.add(new SimpleGrantedAuthority(ROLE_PREFIX + member.getRole().name()));
return simpleGrantedAuthority;
}
@Override
public String getName() {
return attributes.get(attributeName).toString();
}
@Override
public String getUsername() {
return member.getMemberId();
}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
Project Github Repo.
https://github.com/1nthebleakmidwinter/Urcarcher_Card-Service_ShinhanDS-Academy
References
- https://velog.io/@leesoeun98/OAuth%EC%9D%98-%EA%B0%9C%EB%85%90-%EB%B0%8F-%ED%9D%90%EB%A6%84-%EC%A0%95%EB%A6%AC
- https://velog.io/@komment/Spring-Boot-OAuth-2.0-JWT%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84-2-%EC%95%A0%ED%94%8C%ED%8E%B8
- https://hunbae.tistory.com/
- https://velog.io/@goat_hoon/Spring-Security%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-OAuth-%EC%A0%81%EC%9A%A9%EA%B8%B0-Google
- https://velog.io/@black_han26/Spring-Security-OAuth-%EA%B0%9C%EB%85%90-%EB%B0%8F-%EB%8F%99%EC%9E%91-%EB%B0%A9%EC%8B%9D
- https://darrenlog.tistory.com/40
- https://dbjh.tistory.com/77
- https://yelimkim98.tistory.com/48
'Spring boot > Spring security' 카테고리의 다른 글
[Spring boot] OAuth(Google) & JWT Login (6) - JWT + 쿠키 (0) | 2024.11.11 |
---|---|
[Spring boot] OAuth(Google) & JWT Login (5) - OAuth 동작 ② (14) | 2024.11.06 |
[Spring boot] OAuth(Google) & JWT Login (4) - OAuth 동작 ① (1) | 2024.11.04 |
[Spring boot] OAuth(Google) & JWT Login (3) - Security 구성 및 동작 (5) | 2024.10.31 |
[Spring boot] OAuth(Google) & JWT Login (2) - 자체 로그인 동작 구조 (6) | 2024.10.28 |