1. 스프링 시큐리티와 스프링 시큐리티 OAuth2.0 클라이언트
스프링 시큐리티는 막강한 인증과 인가 기능을 가진 프레임워크로, 사실상 스프링 기반의 애플리케이션에서는 보안을 위한 표준으로 보면 된다. 스프링의 대부분 프로젝트들처럼 확장성을 고려한 프레임워크이다 보니 다양한 요구사항을 손쉽게 추가하고 변경할 수 있다.
많은 서비스에서 왜 소셜 로그인을 사용하는 걸까?
직접 구현 시에는 로그인 시 보안, 회원가입 시 이메일 혹은 전화번호 인증, 비밀번호 찾기, 비밀번호 변경, 회원정보 변경을 모두 구현해야한다. 이를 모두 구글, 페이스북, 네이버등에 맡기게 되면 서비스 개발에 집중할 수 있기 때문이다.
2. 구글 서비스 등록
구글 서비스에 신규서비스를 생성해야한다. 여기서 발급된 인증정보(clientId/clientSecret)을 통해서 로그인 기능과 소셜 서비스 기능을 사용할 수 있으니 무조건 발급받고 시작해야한다.
1) 새 프로젝트 만들기
사용자 인증 정보에는 여러 메뉴가 있는데 이 중 구현할 소셜 로그인은 OAuth 클라이언트 ID로 구현한다. [OAuth 클라이언트 ID]항목을 클릭한다. > [동의 화면 구성] 버튼을 클릭한다.
- 애플리케이션 이름 : 구글 로그인 시 사용자에게 노출될 애플리케이션 이름
- 지원이메일 : 사용자 동의 화면에서 노출될 이메일 주소, 보통은 서비스의 help 이메일 주소 사용
- Google API 범위 : 이번에 등록할 구글 서비스에서 사용할 범위 목록
2) OAuth 클라이언트 ID 만들기
[API 및 서비스] > [사용자 인증 정보] > [사용자 인증 정보 만들기] > [OAuth 클라이언트 ID 만들기]
- 승인된 리디렉션 URI
- 서비스에서 파라미터로 인증 정보를 주었을 때 인증이 성공하면 구글에서 리다이렉트할 URL
- 스프링 부트 2 버전 시큐리티에서는 기본적으로 {도메인}/login/oauth2/code/{소셜서비스코드}로 리다이렉트 URL을 지원
- 사용자가 별도로 리다이렉트 URL을 지원하는 Controller를 만들 필요가 없다. 시큐리티에서 이미 구현
클라이언트 ID와 클라이언트 보안 비밀 코드를 프로젝트에 설정
- application-oauth 등록
- src/main/resources/ 디렉토리에 application-oauth.properties 생성
spring.security.oauth2.client.registration.google.client-id=클라이언트 ID
spring.security.oauth2.client.registration.google.client-secret=클라이언트 보안 비밀
spring.security.oauth2.client.registration.google.scope=profile, email
- scope=profile, email
- 많은 예제에서는 이 scope를 별도로 등록하지 않음 -> 기본 값이 openid,profile, email 이기 때문
- profile, email을 등록한 이유 -> openid라는 scope가 있으면 Open Id Provider로 인식
- 이렇게 되면 OpenId Provider인 구글과 그렇지 않은 서비스(네이버)로 나눠 각각 OAuth2Service를 만들어야 함.
- 하나의 OAuth2Service로 사용하기 위해 일부러 openid scope 빼고 등록
스프링 부트에서는 properties의 이름을 application-xxx.properties로 만들면 xxx라는 이름의 profile이 생성되어 이를 통해 관리할 수 있다. 즉, profile=xxx라는 식으로 호출하면 해당 properties의 설정들을 가져올 수 있다.
application.properties에서 application-oauth.properties를 포함하도록 하여 호출한다.
spring.profiles.include = oauth
- .gitignore 등록
구글 로그인을 위한 클라이언트 ID와 클라이언트 보안 비밀은 보안이 중요한 정보이다. 이들이 외부에 노출될 경우 개인 정보를 가져갈 수 있는 취약점이 될수 있기에 깃허브에 올라가지 않도록 관리해야한다.
application-oauth.properties
3. 구글 로그인 연동하기
1) 사용자 정보를 담당할 도메인 User 클래스 생성
- domain 아래에 user 패키지 생성
@Getter
@NoArgsConstructor
@Entity
public class User extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String email;
@Column
private String picture;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
@Builder
public User(String name, String email, String picture, Role role) {
this.name = name;
this.email = email;
this.picture = picture;
this.role = role;
}
public User update(String name, String picture) {
this.name = name;
this.picture = picture;
return this;
}
public String getRoleKey() {
return this.role.getKey();
}
}
(1) @Enumerated(EnumType.STRING)
- JPA로 데이터베이스 저장할 때 Enum 값을 어떤 형태로 저장할 지를 결정
- 기본적으로 int로 된 숫자가 저장
- 숫자로 저장되면 데이터베이스로 확인할 때 그 값이 무슨 코드를 의미하는지 알 수가 없기에 문자열로 저장
2) 각 사용자의 권한을 관리할 Enum 클래스 Role를 생성
@Getter
@RequiredArgsConstructor
public enum Role {
GUEST("ROLE_GUEST","손님"),
USER("ROLE_USER","일반 사용자");
private final String key;
private final String title;
}
스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야한다.
3) User의 CRUD를 책임질 UserRepository도 생성
public interface UserRepository extends JpaRepository<User,Long> {
Optional<User> findByEmail(String email);
}
- findByEmail() : 소셜 로그인으로 반환되는 값 중 email을 통해 이미 생성된 사용자인지 처음 가입하는 사용자인지 판단하기 위한 메소드
4) 스프링 시큐리티 설정
- build.gradle에 스프링 시큐리티 관련 의존성 하나 추가
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
- OAuth 라이브러리를 이용한 소셜 로그인 설정코드 작성
config.auth 패키지 생성 -> 시큐리티 관련 클래스는 모두 이곳에
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomOAuth2UserService customOAuth2UserService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.headers().frameOptions().disable()
.and()
.authorizeRequests()
.antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**").permitAll()
.antMatchers("/api/v1/**").hasRole(Role.USER.name())
.anyRequest().authenticated()
.and()
.logout()
.logoutSuccessUrl("/")
.and()
.oauth2Login()
.userInfoEndpoint()
.userService(customOAuth2UserService);
}
}
(1) @EnableWebSecurity : Spring Security 설정을 활성화
(2) csrf().disable().headers().frameOptions().disable() : h2-console화면을 사용하기 위해 해당옵션들 disable
(3) authorizeRequest
- URL 별 권한 권리 설정 옵션의 시작점
- authorizeRequets가 선언되어야지 antMarchers 옵션 사용 가능
(4) antMarchers
- 권한 관리 대상 지정 옵션
- URL, HTTP 메소드 별 관리 가능
- "/"등 지정된 URL들은 permitAll() 옵션을 통해 전체 열람 권한을 줌
- POST 메소드이면서, "/api/v1/**" 주소를 가진 API는 USER 권한을 가진 사람만 가능하도록
(5) anyRequest
- 설정된 값들 이외 나머지 URL들을 나타냄
- 여기서는 authenticated()를 추가하여 나머지 URL들은 모두 인증된 사용자들(로그인한 사용자)에게만 가능하도록
(6) logout().logoutSuccessUrl("/") : 로그아웃 기능에 대한 여러 설정의 진입점, 로그아웃 성공 -> / 주소로 이동
(7) oauth2Login() : OAuth2 로그인 기능에 대한 여러 설정의 진입점
(8) userInfoEndpoint() : OAuth2 로그인 성공 이후 사용자 정보를 가저올 때의 설정들을 담당
(9) userService()
- 소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록
- 리소스 서버(소셜 서비스)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능들을 명시할 수 있음
- CustomOAuth2UserService.java 작성
구글 로그인 이후 가져온 사용자 정보(email, name, picture 등)을 기반으로 가입 및 정보 수정, 세션 저장 등의 기능 수행
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
private final HttpSession httpSession;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUserNameAttributeName();
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
User user = saveOrUpdate(attributes);
httpSession.setAttribute("user", new SessionUser(user));
return new DefaultOAuth2User(Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())), attributes.getAttributes(), attributes.getNameAttributeKey());
}
private User saveOrUpdate(OAuthAttributes attributes) {
User user = userRepository.findByEmail(attributes.getEmail())
.map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
.orElse(attributes.toEntity());
return userRepository.save(user);
}
}
(1) registraionId
- 현재 로그인 진행 중인 서비스를 구분하는 코드
- 지금은 구글만 사용하는 불필요한 값, 이후 네이버 로그인 연동 시에 네이버 로그인인지, 구글 로그인인지 구분하기 위해 필요
(2) userNameAttributeName
- OAuth2 로그인 진행 시 키가 되는 필드값, PK와 같은 의미
- 구글의 경우 기본적으로 코드를 지원, 네이버 카카오 등은 기본지원 X
- 구글의 기본코드 "sub"
- 이후 네이버 로그인과 구글 로그인을 동시 지원할 때 사용
(3) OAuthAttributes
- OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담은 클래스
- 이후 네이버 등 다른 소셜 로그인도 이 클래스를 사용
(4) SessionUser : 세션에 사용자 정보를 저장하기 위한 Dto 클래스
(5) saveOrUpdate : 구글 사용자 정보가 업데이트 되었을 때 사용자의 정보가 변경되면 User Entity에도 반영
- OAuthAttributes.java 생성
OAuthAttributes는 Dto로 보기에 config.auth.dto 패키지 생성
@Getter
public class OAuthAttributes {
private Map<String, Object> attributes;
private String nameAttributeKey;
private String name;
private String email;
private String picture;
@Builder
public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey, String name, String email, String picture){
this.attributes = attributes;
this.nameAttributeKey = nameAttributeKey;
this.name = name;
this.email = email;
this.picture = picture;
}
public static OAuthAttributes of (String registrationId, String userNameAttributeName, Map<String, Object> attributes){
return ofGoogle(userNameAttributeName, attributes);
}
private static OAuthAttributes ofGoogle(String userNameAttrubuteName, Map<String, Object> attributes){
return OAuthAttributes.builder()
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.picture((String) attributes.get("picture"))
.attributes(attributes)
.nameAttributeKey(userNameAttrubuteName)
.build();
}
public User toEntity(){
return User.builder()
.name(name)
.email(email)
.picture(picture)
.role(Role.GUEST)
.build();
}
}
(1) of() : OAuth2User에서 반환하는 사용자 정보는 Map -> 값 하나하나 변환이 필요
(2) toEntity()
- User 엔티티를 생성
- OAuthAttributes에서 엔티티를 생성하는 시점은 처음 가입할 때
- 가입할 때 기본권한을 주기 위해서 role = Role.GUEST로 사용
- SessionUser.java
config.auth.dto 패키지에 생성
SessionUser에는 인증된 사용자 정보만 필요 -> name, email, picture만 필드 생성
@Getter
public class SessionUser {
private String name;
private String email;
private String picture;
public SessionUser(User user){
this.name = user.getName();
this.email = user.getEmail();
this.picture = user.getPicture();
}
}
User 클래스를 사용하지 않고
SessionUser Dto 클래스를 만드는 가?
User 클래스를 그대로 사용했다면?
Failed to convert from type [java.lan.Object]
to type [byte[]] for value 'com.prac.webservice.springboot.domain.user.User@4a43d6'
이는 세션에 저장하기 위해 User 클래스를 세션에 저장하려고 하니 User 클래스에 직렬화를 구현하지 않았다는 의미
User 클래스는 엔티티이기 때문에 언제 다른 엔티티와 관계가 형성될지 모른다. 직렬화 대상에 자식들까지 포함됨으로써 성능 이슈, 부수 효과가 발생할 확률이 높다. 그래서 직렬화 기능을 가진 세션 Dto를 하나 추가로 만드는 것이 이후 운영 및 유지보수에 많은 도움이 된다.
5) 로그인 테스트
- index.mustache에 로그인 버튼과 로그인 성공시 사용자 이름을 보여주는 코드 작성
<div class="row">
<div class="col-md-6">
<a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
{{#userName}}
Logged in as: <span id="user">{{userName}}</span>
<a href="/logout" class="btn btn-info active" role="button">Logout</a>
{{/userName}}
{{^userName}}
<a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
{{/userName}}
</div>
</div>
(1) {{#userName}}
- 머스테치는 다른 언어와 같은 if문 제공 X
- true/false 여부만 판단 -> 항상 최종 값을 넘겨줘야함
- userName이 있다면 userName을 노출시키도록 구성
(2) a href="/logout"
- 스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL -> 별도로 URL에 해당하는 컨트롤러를 만들 필요 X
- SecurityConfig 클래스에서 URL을 변경할 수 있지만, 기본 URL을 사용해도 충분
(3) {{^userName}}
- 머스테치에서 해당값이 존재하지 않는 경우 ^ 사용
- userName이 존재하지 않는다면 로그인 버튼을 노출시키도록 구성
(4) a href="/oauth2/authorization/google"
- 스프링 시큐리티에서 기본적으로 제공하는 로그인 URL -> 별도로 URL에 해당하는 컨트롤러 만들 필요 X
- IndexController.java
index.mustache에서 userName을 사용할 수 있게 IndexController에서 userName을 model에 저장하는 코드
public class IndexController {
private final PostsService postsService;
private final HttpSession httpSession; // HttpSession 코드도 추가합시다.
@GetMapping("/")
public String index(Model model) {
model.addAttribute("posts", postsService.findAllDesc());
SessionUser user = (SessionUser) httpSession.getAttribute("user");
if (user != null){
model.addAttribute("userName", user.getName());
}
return "index";
}
(1) (SessionUser) httpSession.getAttribute("user");
- 앞서 작성된 CustomOAuth2UserService에서 로그인 성공시 세션에 SessionUser를 저장하도록 구성
- 즉, 로그인 성공 시 httpSession.getAttribute("user")에서 값을 가져올 수 있음
(2) if(user != null)
- 세션에 저장된 값이 있을 때만 model에 userName으로 등록
- 세션에 저장된 값이 없으면 model엔 아무런 값이 없는 상태이니 로그인 버튼이 보이게 됨
- 테스트
위와 같이 403(권한 거부) 에러가 발생한다. h2-console로 가서 사용자의 role을 USER로 변경하여 권한 변경을 한다.
update user set role='USER';
세션에는 이미 GUEST인 정보로 저장되어 있으니 로그아웃한 후 다시 로그인하여 세션 정보를 최신 정보로 갱신한 후에 글 등록을 한다.
정상적으로 글이 등록 되었다.
'스프링부트와 AWS로 혼자 구현하는 웹서비스' 카테고리의 다른 글
Part12. AWS 서버 환경 구축 (0) | 2024.01.18 |
---|---|
Part11. 로그인 기능 구현 (2) (1) | 2024.01.16 |
Part 9. 게시글 화면 만들기 (2) (0) | 2024.01.13 |
Part 8. 게시글 화면 만들기 (1) (2) | 2024.01.13 |
Part 7. JPA Auditing으로 생성시간/수정시간 자동화하기 (1) | 2024.01.11 |