웹을 사용하면서 겪는 여러 불편함 중 가장 많이 겪는 어려움은 매번 비밀번호를 기억해야 한다는 점이 있다.
따라서 OAuth 서비스를 이용하여 로그인을 처리하면 사용자 관리에 대한 부담을 줄일 수 있다.
[ OAuth(Open Authorization) ] : 서비스를 제공하는 업체들이 각자 다른 방식으로 로그인하지 않도록 공통의 인증 방식을 제공하는 것
웹 내에서 로그인을 구글과 연동하여 프로젝트 생성한다.
1.구글에 프로젝트(서비스)를 등록
OAuth 클라이언트 ID를 만든 후 구글에서 인증된 정보를 다시 현재 프로젝트에 보내는 '리디렉션 URI'를 지정한다.
리디렉션URI를 지정하면 클라이언트ID와 비밀번호가 생성되는데 이를 기억한다.
2.프로젝트 내 구글 설정
2-1.build.gradle 수정
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client
2-2 application.properties 수정
spring.profiles.include=oauth
2-3.appication-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=email
2-4. SecurityConfig 클래스수정 ( 여기선 예제로 사용할 클래스이며 모든 시큐리티 관련 설정을 추가하는 곳)
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/sample/all").permitAll() //anthorizeRequests : 인증이 필요한 자원들을 설정
// antMatchers : 앤트 스타일의 패턴으로 원하는 자원을 선택
// permitAll : 모든 사용자에게 허락
.antMatchers("/sample/member").hasRole("USER");
http.formLogin(); // 인가/인증에 문제시 로그인 화면
http.csrf().disable();
http.oauth2Login()
}
실제 로그인 시에 OAuth를 사용한 로그인이 가능하도록 HttpSecurity설정을 변경 -> HttpSecurity의 oauth2Login() 추가
위와 같이 Google 로그인이 추가된 것을 확인할 수 있다.
이제 가장 먼저 해야 하는 작업은 구글과 같은 서비스에서 로그인 처리가 끝난 결과를 가져오는 작업을 사용할 수 있는 환경을 구성하는 것이다. 여기서 OAuth2UserService를 알아야 한다.
OAuth2UserService : OAuth의 인증 결과를 처리 (UserDetailsService의 OAuth 버전)
OAuth2UserService 인터페이스는 여러 개의 구현 클래스를 가지고 있는데 그 중 DefaultOAuth2UserService 를 사용하여 예제를 진행한다.
DefaultOAuth2UserService 클래스를 상속하여 구현하기 때문에 service 패키지내에 ClubOAuth2UserDetailsService 클래스를 구성 ( 전 글의 예제를 그대로 진행 )
ClubOAuth2UserDetailsService 클래스는 생성 뒤 제일 먼저 동작하는지 확인하는 것이 중요하므로 @Log4js를 이용하여 이를 확인한다. 클래스를 생성한 후에 @Service를 추가하면 별도의 추가적인 설정 없이도 자동으로 스프링의 빈으로 등록된다.
@Log4j2
@Service
public class ClubOAuth2UserDetailsService extends DefaultOAuth2UserService {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException{
log.info("--------------------------------------");
log.info("userRequest: " + userRequest); //org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest객체
retuurn super.loadUser(userRequest);
}
}
위와 같이 코드 입력 후 프로젝트를 시작하고 '/sample/member'로 로그인을 시도하면 위의 코드가 서버에서 정상적으로 동작하는 것을 확인할 수 있다.
loadUser()는 OAuth2UserRequest 타입의 파라미터와 OAuth2User 타입의 리턴 타입을 반환한다. 여기서 문제는 기존의 로그인 처리에 사용하던 파라미터와 리턴 타입과는 다르기 때문에 이를 변환해서 처리해야만 브라우저와 컨트롤러의 결과를 일반적인 로그인과 동일하게 사용할 수 있다.
loadUser()에서 사용하는 OAuth2UserRequest는 현재 어떤 서비스를 통해서 로그인이 이루어졌는지 알아내고 전달된 값들을 추출할 수 있는 데이터를 Map<String,Object>의 형태로 사용할 수 있다. 최대한 많은 정보를 조회하기 위해서 loadUser()를 아래와 같이 수정한다.
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException{
log.info("--------------------------------------");
log.info("userRequest: " + userRequest); //org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest객체
String clientName = userRequest.getClientRegistration().getClientName();
log.info("clientName: " + clientName); //Google로 출력
log.info(userRequest.getAdditionalParameters());
OAuth2User oAuth2User = super.loadUser(userRequest);
log.info("==========================");
oAuth2User.getAttributes().forEach((k,v) -> {
log.info(k +":" + v);
});
return oAuth2User;
}
OAuth로 연결한 클라이언트 이름과 이때 사용한 파라미터들을 출력하고 처리 결과로 나오는 OAuth2User 객체의 내부에 어떤 값들이 있는지 확인하는 코드이다.
위와 같이 sub, picture, email, email_verified 항목이 출력되는 것을 볼 수 있다.
이제 OAuth2User를 이용하면 이메일 주소를 알아낼 수 있으므로 DB에 추가하는 작업을 진행한다.
우선 필요한 객체를 주입받는 구조로 변경하고 ClubMemberRepository를 이용한다.
@Log4j2
@Service
@RequiredArgsConstructor
public class ClubOAuth2UserDetailsService extends DefaultOAuth2UserService {
private final ClubMemberRepository repository;
private final PasswordEncoder passwordEncoder;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException{
log.info("--------------------------------------");
log.info("userRequest: " + userRequest); //org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest객체
String clientName = userRequest.getClientRegistration().getClientName();
log.info("clientName: " + clientName); //Google로 출력
log.info(userRequest.getAdditionalParameters());
OAuth2User oAuth2User = super.loadUser(userRequest);
log.info("==========================");
oAuth2User.getAttributes().forEach((k,v) -> {
log.info(k +":" + v); // sub,picture,email,email,email_verified,EMAIL등이 출력
});
String email = null;
if(clientName.equals("Google")){ //구글을 이용하는 경우
email = oAuth2User.getAttribute("email");
}
log.info("EMAIL: "+email);
ClubMember member = saveSocialMember(email); // 조금 뒤에 사용
return oAuth2User;
}
ClubMemberRepository를 이용하여 소셜 로그인한 이메일을 처리하는 부분은 아래와 같이 구성한다.
private ClubMember saveSocialMember(String email){
//기존에 동일한 이메일로 가입한 회원이 있는 경우에는 그대로 조회만
Optional<ClubMember> result = repository.findByEmail(email,true);
if(result.isPresent()){
return result.get();
}
//없다면 회원 추가 패스워드는 1111 이름은 그냥 이메일 주소로
ClubMember clubMember = ClubMember.builder().email(email)
.name(email)
.password(passwordEncoder.encode("1111"))
.fromSocial(true)
.build();
clubMember.addMemberRole(ClubMemberRole.USER);
repository.save(clubMember);
return clubMember;
}
최종 결과를 가지고 있는 OAuth2User에서 getAttribute()를 사용하여 이메일 정보를 추출한 뒤, 현재 데이터베이스에 있는 사용자가 아니라면 자동으로 회원 가입을 처리한다.
DefualtOAuth2UserService의 loadUser()의 경우 일반적인 로그인과 다르게 OAuth2User 타입의 객체를 반환해야 한다. 앞에서 만들어진 컨트롤러는 파라미터로 ClubAuthMemberDTO 타입을 사용하기 때문에 OAuth2User 타입을 ClubAuthMemberDTO 타입으로 사용할 수 있도록 처리해줘야 한다.
다행인점은 OAuth2User 타입은 인터페이스로 설계되어 있으므로 ClubAuthMemberDTO를 수정해서 이 문제를 해결한다.
@Log4j2
@Getter
@Setter
@ToString
public class ClubAuthMemberDTO extends User implements OAuth2User { // DTO 역할을 수행하는 클래스인 동시에 인가/인증 작업에 사용할 수 있음
private String email;
private String password;
private String name;
private boolean fromSocial;
private Map<String, Object> attr;
public ClubAuthMemberDTO(
String username,
String password,
boolean fromSocial,
Collection<? extends GrantedAuthority> authorities,Map<String,Object> attr){
this(username, password, fromSocial, authorities);
this.attr = attr;
}
public ClubAuthMemberDTO(
String username,
String password,
boolean fromSocial,
Collection<?extends GrantedAuthority> authorities){
super(username, password, authorities);
this.email = username;
this.password = password;
this.fromSocial = fromSocial;
}
@Override
public Map<String, Object> getAttributes(){
return this.attr;
}
}
ClubAuthMemberDTO 클래스는 OAuth2User 인터페이스를 구현하도록 수정하고, OAuth2User는 Map 타입으로 모든 인증 결과를 attributes 이름으로 가지고 있기 때문에 ClubAuthMember 역시 attr 변수를 만들어 주고 getAttributes() 메서드를 오버라이드 해준다.
이제 loadUser()를 수정해준다.
@Log4j2
@Service
@RequiredArgsConstructor
public class ClubOAuth2UserDetailsService extends DefaultOAuth2UserService {
private final ClubMemberRepository repository;
private final PasswordEncoder passwordEncoder;
private ClubMember saveSocialMember(String email){
//기존에 동일한 이메일로 가입한 회원이 있는 경우에는 그대로 조회만
Optional<ClubMember> result = repository.findByEmail(email,true);
if(result.isPresent()){
return result.get();
}
//없다면 회원 추가 패스워드는 1111 이름은 그냥 이메일 주소로
ClubMember clubMember = ClubMember.builder().email(email)
.name(email)
.password(passwordEncoder.encode("1111"))
.fromSocial(true)
.build();
clubMember.addMemberRole(ClubMemberRole.USER);
repository.save(clubMember);
return clubMember;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException{
log.info("--------------------------------------");
log.info("userRequest: " + userRequest); //org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest객체
String clientName = userRequest.getClientRegistration().getClientName();
log.info("clientName: " + clientName); //Google로 출력
log.info(userRequest.getAdditionalParameters());
OAuth2User oAuth2User = super.loadUser(userRequest);
log.info("==========================");
oAuth2User.getAttributes().forEach((k,v) -> {
log.info(k +":" + v); // sub,picture,email,email,email_verified,EMAIL등이 출력
});
String email = null;
if(clientName.equals("Google")){ //구글을 이용하는 경우
email = oAuth2User.getAttribute("email");
}
log.info("EMAIL: "+email);
// ClubMember member = saveSocialMember(email); // 조금 뒤에 사용
//
// return oAuth2User;
ClubMember member = saveSocialMember(email);
ClubAuthMemberDTO clubAuthMember = new ClubAuthMemberDTO(
member.getEmail(),
member.getPassword(),
true, //fromSocial
member.getRoleSet().stream().map(
role -> new SimpleGrantedAuthority("ROLE_"+role.name()))
.collect(Collectors.toList()),
oAuth2User.getAttributes()
);
clubAuthMember.setName(member.getName());
return clubAuthMember;
}
}
saveSocialMember()한 결과로 나오는 ClubMember를 이용해서 ClubMemberDTO를 구성하게 하고, OAuth2User의 모든 데이터는 ClubAuthMemberDTO의 내부로 전달해서 필요한 순간에 사용할 수 있도록 한다.
위와 같은 구조를 가진 후 구글 계정을 이용하더라도 이메일 주소를 사용할 수 있게 된다.
이제 자동으로 회원 가입이 되는 경우 아래사항을 고려해야 할 것이다.
- 패스워드가 모두 '1111'로 처리되기에 만일 이메일을 알고 있다면 모든 패스워드는 1111로 고정된다는 점
- 사용자의 이메일 외에도 이름을 닉네임처럼 사용할 수 없다는 점
따라서 현재 코드에 있는 fromSocial 속성값을 이용한다.
소셜로 가입한 이메일이 있더라도 일반적인 폼방식으로 로그인이 불가능하도록 처리한다.
만일 소셜 로그인을 한 사용자에 한해서 서비스에서 사용할 본인의 이름이나 패스워드를 수정하고자 한다면 로그인 이후에 폼 로그인과 달리 회원 정보를 수정할 수 있는 페이지로 이동할 필요가 있다.
[ Handler 설정 ]
스프링 시큐리티 로그인 관련 처리에는 AuthenticationSuccessHandler, Authenti-cationFailureHandler 인터페이스를 제공한다.
이는 인증이 성공하거나 실패한 후에 처리를 지정하는 용도로 사용한다.
HttpSecurity의 FormLogin()이나 oauth2Login() 후에는 이러한 핸들러를 설정할 수 있다.
oauth2Login() 이후에 이를 적용한다고 가정하고 예제를 진행한다.
먼저 handler패키지를 생성한 후 클래스를 생성한다.
@Log4j2
public class ClubLoginSuccessHandler implements AuthenticationSuccessHandler {
// Handler 인터페이스는 인증이 성공하거나 실패한 후에 처리를 지정하는 용도
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication)
throws IOException, ServletException{
log.info("----------------------------------------");
log.info("onAuthenticationSuccess");
}
}
설정을 위해서 SecurityConfig 클래스를 수정한다.
@Configuration
@Log4j2
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter { // 모든 시큐리티 관련 설정을 추가하는 곳
@Bean
PasswordEncoder passwordEncoder() { // 'bcrypt'라는 해시 함수를 이용해서 패스워드를 암호화하는 목적으로 설계된 클래스
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/sample/all").permitAll() //anthorizeRequests : 인증이 필요한 자원들을 설정
.antMatchers("/sample/member").hasRole("USER");
http.formLogin(); // 인가/인증에 문제시 로그인 화면
http.csrf().disable();
http.oauth2Login().successHandler(successHandler());
}
@Bean
public ClubLoginSuccessHandler successHandler(){
return new ClubLoginSuccessHandler(); //새로운 Handler 추가
}
}
수정된 내용은 ClubLoginSuccessHandler 를 생성하는 메서드를 추가하고, http.oauth2Login() 뒤로 successHandler()를 지정한 부분이다.
위와 같이 설정 후 프로젝트를 시작하여 '/sample/member' 경로를 구글 로그인으로 진행하면 빈 화면만이 보이게 된다. Handler가 정상적으로 동작하는 것을 로그를 통해서 확인할 수 있다.
일반적인 로그인은 기존과 동일하게 이동하고 소셜 로그인은 회원 정보를 수정하는 경로로 이동하도록 구현한다.
RedirectStrategy 인터페이스는 주로 구현 클래스인 DefaultRedirectStrategy 클래스를 사용하여 처리하는데 소셜 로그인은 대상 URL을 다르게 지정하는 용도로 사용한다.
예를 들어 소셜 로그인으로 로그인한 사용자의 패스워드가 '1111'인 경우 회원 정보를 수정해야겠다고 판단하면 PasswordEncoder를 주입해서 패스워드를 확인하고 회원 수정 페이지로 이동시킬 수 있다.
@Log4j2
public class ClubLoginSuccessHandler implements AuthenticationSuccessHandler {
// Handler 인터페이스는 인증이 성공하거나 실패한 후에 처리를 지정하는 용도
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
private PasswordEncoder passwordEncoder;
public ClubLoginSuccessHandler(PasswordEncoder passwordEncoder){
this.passwordEncoder = passwordEncoder;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication)
throws IOException, ServletException{
log.info("----------------------------------------");
log.info("onAuthenticationSuccess");
ClubAuthMemberDTO authMember = (ClubAuthMemberDTO)authentication.getPrincipal();
boolean fromSocial = authMember.isFromSocial();
log.info("Need Modify Member?" + fromSocial);
boolean passwordResult = passwordEncoder.matches("1111", authMember.getPassword());
if(fromSocial && passwordResult){
redirectStrategy.sendRedirect(request, response, "/member/modify?from=social");
}
}
}
SuccessHandler는 PasswordEncoder가 필요하므로 SecurityConfig 클래스를 수정해준다.
@Bean
public ClubLoginSuccessHandler successHandler(){
return new ClubLoginSuccessHandler(passwordEncoder()); //새로운 Handler 추가
}
위의 코드가 적용된 후 소셜 로그인을 하는 경우 "/member/modify?from=social" 로 Redirect된다.
[ Remember me와 @PreAuthorize ]
스프링 시큐리티의 편리한 기능 중 '자동 로그인'이라고 불리는 'Remember me'가 있다. 이는 웹의 인증 방식 중 '쿠키'를 사용하는 방식으로 한번 로그인한 사용자가 브라우저를 닫은 후에 다시 서비스에 접속해도 로그인 절차 없이 바로 로그인 처리가 진행된다.
Remember me 설정은 간단하다. SecurityConfig에 아래와 같이 설정을 추가하고 UserDetailsService를 지정한다.
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/sample/all").permitAll() //anthorizeRequests : 인증이 필요한 자원들을 설정
.antMatchers("/sample/member").hasRole("USER");
http.formLogin(); // 인가/인증에 문제시 로그인 화면
http.csrf().disable();
http.logout();
http.oauth2Login().successHandler(successHandler());
http.rememberMe().tokenValiditySeconds(60*60*24*7).userDetailsService(userDetailsService); //7days
}
마지막 라인의 rememberMe()를 적용할 때는 쿠키를 얼마나 유지할 것 인지 지정한다. 초 단위로 설정하므로 위의 코드는 7일간 쿠키가 유지되도록 지정하였다.
프로젝트를 시작하면 체크박스로 'Remember me on this computer'가 출력되는데 이를 클릭하고 로그인을 시도하면 쿠키가 생성된다. 주의할 점은 소셜 로그인으로는 remember-me 쿠키는 생성되지 않는다.
SecurityConfig 를 사용해서 지정된 URL에 접근 제한을 거는 방식도 나쁘진 않지만, 매번 URL을 추가할 때마다 이를 설정하는 일은 번거롭다. 시큐리티는 이런 설정을 어노테이션만으로 지정할 수 있다.
따라서 두 가지를 추가해준다.
@EnableGlobalMethodSecurity 적용
접근 제한이 필요한 컨트롤러의 메서드에 @PreAuthorize 적용
일반적인 시큐리티 관련 설정 클래스에 @EnableGlobalMethodSecurity 적용해준다.
securedEnabled는 예전 버전의 @Secure 어노테이션이 사용 가능한지 지정하고, @PreAuthorize를 이용하기 위해서 prePostEnable 속성을 사용한다.
@Override
protected void configure(HttpSecurity http) throws Exception {
// http.authorizeRequests()
// .antMatchers("/sample/all").permitAll() //anthorizeRequests : 인증이 필요한 자원들을 설정
// .antMatchers("/sample/member").hasRole("USER");
http.formLogin(); // 인가/인증에 문제시 로그인 화면
http.csrf().disable();
http.logout();
http.oauth2Login().successHandler(successHandler());
http.rememberMe().tokenValiditySeconds(60*60*24*7).userDetailsService(userDetailsService); //7days
}
접근 제한을 담당하는 부분의 코드를 주석으로 처리하고 프로젝트를 실행해서 '/sample/admin'을 접근하면 아무 문제없이 접근되는 것을 확인할 수 있다.
만약 ROLE_ADMIN 권한을 가진 사용자들만 접근이 가능하게 하고 싶다면 Controller를 다음과 같이 수정한다.
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin")//관리자 권한이 있는 사용자만 접근
public void exAdmin(){
log.info("exAdmin...................");
}
가끔 특별히 정해진 사용자만 해당 URL을 처리하게 하고 싶을 때 @PreAuthorize의 value 표현식은 '#'과 같은 특별한 기호나 authentication과 같은 내장 변수를 이용할 수 있다. 예를 들어 로그인한 사용자 중에서 'user95@zerock.org'인 사용자만 접근이 가능하게 만들고 싶으면 아래와 같이 코드를 적용할 수 있다.
@PreAuthorize("#clubAuthMember != null && #clubAuthMember.username eq\"user95@zerock.org\"")
@GetMapping("/exOnly")
public String exMemberOnly(@AuthenticationPrincipal ClubAuthMemberDTO clubAuthMember){
log.info("exMemberOnly......................");
log.info(clubAuthMember);
return "/sample/admin";
}
만약 다른 'user95@zerock.org'가 아닌 다른 사용자가 '/sample/exOnly'를 접근하면 에러 페이지를 보게된다.
'코드로 배우는 스프링부트 웹 프로젝트' 카테고리의 다른 글
Springboot) 콘솔창에서 빌드후 실행 (0) | 2022.05.06 |
---|---|
Springboot) 웹브라우저 <> 서버 <> 스프링 컨테이너 기본 (0) | 2022.05.06 |
Springboot) UserDetailsService (0) | 2022.01.24 |
Springboot) Springsecurity : AuthenticationManager , AuthenticationProvider , UserDetailsService (0) | 2022.01.21 |
Springboot) RedirectAttributes, addFlashAttribute() (0) | 2022.01.17 |