Oauth2.0 기능 추가

뚜우웅이·2024년 3월 8일

OAuth2.0이란?

OAuth 2.0은 인터넷 사용자가 웹, 모바일 또는 데스크톱 애플리케이션을 통해 다른 웹 사이트 또는 앱에 대한 접근 권한을 안전하게 제공하기 위한 프로토콜입니다. 이러한 접근 권한은 인증 및 권한 부여를 통해 관리됩니다.

사용 이유

  1. 보안: OAuth 2.0은 사용자 비밀번호를 애플리케이션에 직접 제공하지 않고 안전한 방식으로 인증을 처리합니다. 액세스 토큰을 사용하여 권한을 부여하고 인증을 수행합니다. 이는 사용자 비밀번호 노출 및 애플리케이션에 대한 보안 위험을 줄여줍니다.
  1. 사용자 경험: OAuth 2.0은 사용자가 애플리케이션에 로그인할 필요 없이 기존의 인증 정보(예: 구글, 페이스북, 트위터 등)를 사용하여 애플리케이션에 접근할 수 있도록 해줍니다. 이를 통해 사용자는 편리하고 빠른 방식으로 애플리케이션에 접근할 수 있습니다.
  1. 권한 부여: OAuth 2.0은 애플리케이션에 대한 접근 권한을 관리하는 강력한 도구입니다. 사용자는 애플리케이션에 대한 특정 권한(예: 프로필 정보, 이메일 주소, 친구 목록 등)을 부여할 수 있으며, 애플리케이션은 해당 권한을 사용하여 사용자의 데이터에 접근할 수 있습니다.
  1. 표준화: OAuth 2.0은 산업 표준으로 인정받고 있으며, 많은 인증 및 권한 부여 시나리오에 적용할 수 있습니다. 이는 다양한 애플리케이션 및 서비스 간에 통합을 용이하게 해줍니다.

구성요소

리소스 소유자(Resource Owner): 리소스 소유자는 보호되는 리소스(예: 사용자 계정, 사진, 동영상 등)에 대한 접근 권한을 가지고 있는 사용자입니다. 일반적으로 웹 서비스의 사용자가 리소스 소유자 역할을 수행합니다.

클라이언트(Client): 클라이언트는 리소스에 접근하려는 애플리케이션 또는 서비스를 의미합니다. 클라이언트는 리소스 소유자로부터 인증 및 권한 부여를 받아 액세스 토큰을 획득하고, 이를 사용하여 리소스에 접근합니다.

인증 서버(Authorization Server): 인증 서버는 클라이언트에 대한 인증 및 권한 부여를 처리하는 서버입니다. 클라이언트가 인증 서버에게 인증 요청을 보내면, 인증 서버는 리소스 소유자의 동의를 받고, 액세스 토큰을 발급합니다.

리소스 서버(Resource Server): 리소스 서버는 보호되는 리소스를 호스팅하고, 클라이언트가 액세스 토큰을 제공하여 해당 리소스에 접근할 수 있도록 합니다. 리소스 서버는 액세스 토큰의 유효성을 검증하고, 권한에 따라 리소스에 대한 접근을 제어합니다.

구글 OAuth2.0 설정

build.gradle에 spring-securityOAuth2.0 라이브러리를 추가해줍니다.

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

Google Login API 생성

프로젝트 만들기 버튼을 클릭해줍니다.

OAtuh 동의 화면을 만들어줍니다.


범위는 email, profile, openid를 골라줍니다.

사용자 인증 정보 메뉴로 들어가서 OAuth 클라이언트 ID로 사용자 인증 정보를 만들어줍니다.

oauth 동의 화면에서 add user를 클릭한 후 테스트할 사용자의 구글 이메일을 입력해줍니다.

승인된 리디렉션 URI

  • 서비스에서 파라미터로 인증 정보가 주었을 때 인증이 성공하면 구글에서 리다이렉트할 URL
  • 스프링 부트 2 버전의 시큐리티에서는 기본적으로 도메인주소/login/oauth2/code/소셜서비스코드
  • 리다이렉트 URL을 지원하는 Controller을 생성하지 않아도 됨

application.properties 등록

#google client id
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
#google client password
spring.security.oauth2.client.registration.google.client-secret=&{GOOGLE_CLIENT_SECRET}
spring.security.oauth2.client.registration.google.scope=profile,email

${} 안에 있는 값은 IntelliJ에서 Edit Configurations에 들어가서 환경변수 설정을 해준 값입니다.

application.properites에서 application-oauth.properties를 포함하도록 하기 위해 아래 코드를 추가해줍니다.

spring.profiles.include=oauth

네이버 Oauth2 설정

https://developers.naver.com/apps/#/register?api=nvlogin

application.properties 등록

기존 properties 부분과 oauth를 사용하는 부분을 나눠서 생성했습니다.

server.port = 8088
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USER}
spring.datasource.password=${DB_PASSWORD}
spring.profiles.include=oauth
logging.level.toyproject.springmvcboard.domain.auth2=DEBUG
#google client id
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
#google client password
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
spring.security.oauth2.client.registration.google.scope=profile,email
#naver client id
spring.security.oauth2.client.registration.naver.client-id=${NAVER_CLIENT_ID}
#naver client password
spring.security.oauth2.client.registration.naver.client-secret=${NAVER_CLIENT_SECRET}
spring.security.oauth2.client.registration.naver.scope=name,email
spring.security.oauth2.client.registration.naver.redirect-uri=http://localhost:8088/login/oauth2/code/naver
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.client-name=Naver

#naver provider
spring.security.oauth2.client.provider.naver.authorization-uri = https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri = https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri = https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user-name-attribute = response

네이버와 카카오는 구글과 다르게 provider 등의 설정을 따로 해줘야 합니다.

Oauth2 연동

Security 설정

global -> config -> auth -> SecurityConfig 생성

user 생성

User 테이블

CREATE TABLE `user` (
	`user_id`	BIGINT	NOT NULL AUTO_INCREMENT,
	`name`	VARCHAR(20)	NOT NULL,
	`email`	VARCHAR(50)	NOT NULL,
	`login_id`	VARCHAR(50)	NOT NULL,
	`password`	VARCHAR(255)	NOT NULL,
	`enabled`	TINYINT	NOT NULL	DEFAULT 1,
	`role`	TINYINT	NOT NULL	DEFAULT 0	COMMENT '0: 사용자, 1: 관리자'
);

domain 패키지 밑에 user 패키지를 생성한 후 User Entity를 생성해줍니다.

User Entity

package toyproject.springmvcboard.domain.user;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.mapstruct.Mapper;

import java.sql.Timestamp;

@Entity
@Getter
@NoArgsConstructor
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;
    @NotNull(message = "아이디는 필수 입력 값입니다.")
    private String username;
    @NotNull(message = "이메일은 필수 입력 값입니다.")
    @Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,6}$", message = "이메일 형식이 올바르지 않습니다.")
    private String email;
    @NotNull(message = "비밀번호는 필수 입력 값입니다.")
    @Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,16}", message = "비밀번호는 8~16자 영문 대 소문자, 숫자, 특수문자를 사용하세요.")
    private String password;
    private int enabled;
    private String role;
    private Timestamp createDate;
    // 구글, 네이버, 자체 로그인 정보 저장
    private String provider;
    private String providerId;

    @Builder
    public User(String username, String email, String password, int enabled, String role,
                String provider, String providerId, Timestamp createDate) {
        this.username = username;
        this.email = email;
        this.password = password;
        this.enabled = enabled;
        this.role = role;
        this.provider = provider;
        this.providerId = providerId;
        this.createDate = createDate;
    }

    @Mapper(componentModel = "spring")
    public static interface UserMapper {
        UserDTO UserToUserDTO(User user);

        User UserDTOToUser(UserDTO userDTO);
    }
}


user의 id값, 임시 name값, email, password, 활성화여부, 권한 provider 정보, 가입일을 저장합니다.

UserDTO

package toyproject.springmvcboard.domain.user;

import jakarta.validation.constraints.NotNull;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;

import java.sql.Timestamp;

@Getter
@NoArgsConstructor
public class UserDTO {
    private long id;
    @NotNull
    private String username;
    @NotNull
    private String email;
    private String password;
    @NotNull
    private int enabled;
    @NotNull
    private String role;
    @CreationTimestamp
    private Timestamp createDate;
    @NotNull
    private String provider;
    private String providerId;

    @Builder
    UserDTO(String username, String email, String password, int enabled, String role,
            String provider, String providerId, Timestamp createDate) {
        this.username = username;
        this.email = email;
        this.password = password;
        this.enabled = enabled;
        this.role = role;
        this.provider = provider;
        this.providerId = providerId;
        this.createDate = createDate;
    }
}

User Entity의 정보를 DTO로 넘기기 위한 UserMapper 인터페이스를 생성해줍니다.

UserMapper

package toyproject.springmvcboard.domain.user;

import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;

@Mapper(componentModel = "spring")
public interface UserMapper {
    UserMapper Instance = Mappers.getMapper(UserMapper.class);

    UserDTO UserToUserDTO(User user);

    User UserDTOToUser(UserDTO userDTO);
}

UserRepository

CRUD를 위한 Repository를 작성해줍니다.

package toyproject.springmvcboard.domain.user;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    User findByUsername(String username);

    User findByEmail(String email);

}

UserService

package toyproject.springmvcboard.domain.user;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@Slf4j
public class UserService {

    private final UserRepository userRepository;
    private final UserMapper userMapper;


    public UserService(UserRepository userRepository, UserMapper userMapper) {
        this.userRepository = userRepository;
        this.userMapper = userMapper;
    }

}

소셜 로그인 설정 코드 작성

컨트롤러

package toyproject.springmvcboard.domain.auth2;

import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import toyproject.springmvcboard.domain.user.User;
import toyproject.springmvcboard.domain.user.UserRepository;
import toyproject.springmvcboard.domain.user.UserService;

import java.sql.Timestamp;

@Controller
@RequestMapping("/account")
@Slf4j
public class LoginController {
    private final UserService userService;
    private final BCryptPasswordEncoder passwordEncoder;
    private final UserRepository userRepository;

    public LoginController(UserService userService, BCryptPasswordEncoder passwordEncoder, UserRepository userRepository) {
        this.userService = userService;
        this.passwordEncoder = passwordEncoder;
        this.userRepository = userRepository;
    }

    @GetMapping("/login")
    public String login(@RequestParam(value = "error", required = false) String error,
                        @RequestParam(value = "exception", required = false) String exception,
                        Model model) {

        model.addAttribute("error", error);
        model.addAttribute("exception", exception);
        return "/account/login";
    }

    @PostMapping("/signup")
    public String processSignup(@RequestParam String username, @RequestParam String email,
                                @RequestParam String password, @RequestParam String confirmPassword,
                                RedirectAttributes redirectAttributes) {

        if (!password.equals(confirmPassword)) {
            redirectAttributes.addFlashAttribute("signupError", "Password and Confirm Password do not match");
            log.debug("not match = {}", password);
            return "redirect:/account/signup?show=signup";
        }
        // 비밀번호 암호화
        String encodedPassword = passwordEncoder.encode(password);

        // 현재 시간 정보
        Timestamp registrationTime = new Timestamp(System.currentTimeMillis());

        User user = User.builder()
                .username(username)
                .email(email)
                .password(encodedPassword)
                .enabled(1)
                .role("ROLE_USER")
                .provider("custom")
                .providerId("custom")
                .createDate(registrationTime)
                .build();
        // 사용자 정보 저장
        userRepository.save(user);
        log.debug("signup = {}", username);
        return "redirect:/account/signin?show=signin"; // 회원가입 후 로그인 페이지로 리다이렉션
    }
}

로그인 핸들러

성공 핸들러

package toyproject.springmvcboard.domain.auth2;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class CustomAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private RequestCache requestCache = new HttpSessionRequestCache();

    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {

        //기본 url 설정, savedRequest가 null일 경우 설정한 페이지로 보내기 위함이다.
        setDefaultTargetUrl("/");

        // 사용자가 인증을 시도하기 이전에 접근을 시도했던 자원이 없을경우 savedRequest는 null로 반환된다.
        SavedRequest savedRequest = requestCache.getRequest(request, response);
        if(savedRequest != null) {
            String targetUrl = savedRequest.getRedirectUrl();
            redirectStrategy.sendRedirect(request, response, targetUrl);
        }else{
            redirectStrategy.sendRedirect(request, response, getDefaultTargetUrl());
        }
    }
}

실패 핸들러

package toyproject.springmvcboard.domain.auth2;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.net.URLEncoder;

@Component
@Slf4j
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    /*
     * HttpServletRequest : request 정보
     * HttpServletResponse : Response에 대해 설정할 수 있는 변수
     * AuthenticationException : 로그인 실패 시 예외에 대한 정보
     */

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException {

        String errorMessage;

        if (exception instanceof BadCredentialsException) {
            errorMessage = "아이디 또는 비밀번호가 맞지 않습니다. 다시 확인해주세요.";
        } else if (exception instanceof UsernameNotFoundException) {
            errorMessage = "존재하지 않는 계정입니다. 회원가입 후 로그인해주세요.";
        } else if (exception instanceof InternalAuthenticationServiceException) {
            errorMessage = "내부적으로 발생한 시스템 문제로 인해 요청을 처리할 수 없습니다.";
        } else if (exception instanceof AuthenticationCredentialsNotFoundException) {
            errorMessage = "인증 요청이 거부되었습니다. 관리자에게 문의하세요.";
        } else {
            errorMessage = "알 수 없는 오류로 로그인 요청을 처리할 수 없습니다. 관리자에게 문의하세요.";
        }

        log.error("errorMessage = {}", errorMessage);
        errorMessage = URLEncoder.encode(errorMessage, "UTF-8"); /* 한글 인코딩 깨진 문제 방지 */
        setDefaultFailureUrl("/account/login?error=true&exception=" + errorMessage);
        super.onAuthenticationFailure(request, response, exception);
    }
}

spring security로 로그인시 security에서 보내는 에러 코드에 맞게 errormessage를 작성해줍니다.

global 패키지 밑에 config.auth 패키지를 추가해줍니다.
config.auth 패키지에 SecurityConfig 파일을 생성합니다.

package toyproject.springmvcboard.global.config.auth;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import toyproject.springmvcboard.domain.auth2.CustomAuthenticationFailureHandler;
import toyproject.springmvcboard.domain.auth2.CustomAuthenticationSuccessHandler;
import toyproject.springmvcboard.domain.auth2.PrincipalOauth2UserService;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final PrincipalOauth2UserService principalOauth2UserService;
    private final CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;
    private final CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

    public SecurityConfig(PrincipalOauth2UserService principalOauth2UserService,
                          CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler,
                          CustomAuthenticationFailureHandler customAuthenticationFailureHandler) {
        this.principalOauth2UserService = principalOauth2UserService;
        this.customAuthenticationSuccessHandler = customAuthenticationSuccessHandler;
        this.customAuthenticationFailureHandler = customAuthenticationFailureHandler;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable()) // csrf 보안 설정 사용 X
                .authorizeHttpRequests((authorize) -> authorize  // 사용자가 보내는 요청에 인증 절차 수행 필요
                        .requestMatchers("/css/**", "/js/**", "/account/**", "/", "/images/**").permitAll() // 해당 URL은 인증 절차 수행 생략 가능
                        .anyRequest().authenticated())  // 나머지 요청들은 모두 인증 절차 수행해야함
//                .httpBasic(httpBasic -> httpBasic.disable()) // Basic 인증 비활성화

                //자체 로그인
                .formLogin(form -> form
                        .loginPage("/account/login")
                        .loginProcessingUrl("/account/login") // login 주소가 호출이 되면 security 호출
                        .successHandler(customAuthenticationSuccessHandler) // 로그인 성공시 custom success 핸들러를 호출
                        .failureUrl("/account/login?error") // 로그인 실패시 이동할 URL
                        .failureHandler(customAuthenticationFailureHandler) // 로그인 실패시 custom failure 핸들러 호출
                        .defaultSuccessUrl("/")) // 로그인 성공시 이동할 URL

                //소셜 로그인
                .oauth2Login(oauth2 -> oauth2 // OAuth2를 통한 로그인 사용
                        .defaultSuccessUrl("/") // 로그인 성공시 이동할 URL
                        .userInfoEndpoint(userInfo -> userInfo // 사용자가 로그인에 성공하였을 경우,
                                .userService(principalOauth2UserService))) // 해당 서비스 로직을 타도록 설정
                .logout(logout -> logout.logoutSuccessUrl("/")); // 로그아웃 성공시 해당 url로 이동
        return http.build();
    }


    // password 암호화
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

로그인 페이지와 로그인 페이지에 적용되는 css, js 파일을 제외한 모든 파일은 접근제한을 걸어두고 웹 사이트 자체 로그인과, OAuth2 로그인을 구현하기 위한 코드입니다.

spring 6.0 이후에 바뀐 부분이 많아서 기존 SecurityCofig 설정을 마이그레이션하는데 어려움이 있었습니다.

Override -> Bean등록으로 변경
authorizeRequests() -> authorizeHttpRequests()
antMatchers() -> requestMatchers()
access() -> hasAnyRole()
http.build() 반환

http
	.cors()
	.and()
	.csrf().disable(); //변경 전
http
	.cors(cors -> cors.disable())
	.csrf(csrf -> csrf.disable()); //변경 후

이런식으로 변경을 해야 작동합니다.

PrincipalsDetails

PrincipalDetail은 Spring Security에서 사용자의 인증 및 권한 정보를 담은 객체로, UserDetails 인터페이스를 구현한 클래스입니다. 주로 사용자 인증에 성공한 후, 인증된 사용자의 정보를 담아서 보관합니다.

package toyproject.springmvcboard.domain.auth2;

import lombok.Data;
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 toyproject.springmvcboard.domain.user.User;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Map;

@Data
public class PrincipalDetails implements UserDetails, OAuth2User {

    private User user;
    private Map<String, Object> attributes;


    //일반 로그인
    public PrincipalDetails(User user) {
        this.user = user;
    }

    //OAuth 로그인
    public PrincipalDetails(User user, Map<String, Object> attributes) {
        this.user = user;
        this.attributes = attributes;
    }

    //OAuth2User의 메서드
    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }


    // 해당 User의 권한을 리턴
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collect = new ArrayList<>();
        collect.add(new SimpleGrantedAuthority(user.getRole()));
        return collect;
    }

    // 패스워드를 반환
    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    // 계정 만료 여부 반환
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    // 계정 잠금 여부 반환
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    // 패스워드 만료 여부 반환
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    // 계정 사용 가능 여부 반환
    @Override
    public boolean isEnabled() {
        return true;
    }

    @Override
    public String getName() {
        return null;
    }
}

PrincipalDetailsService

UserDetailsService 인터페이스를 구현하는 클래스 중 하나로, Spring Security에서 사용자 정보를 로드하고 제공하는 역할을 하는 서비스입니다. 주로 사용자의 인증(authentication) 과정에서 인증 매니저(AuthenticationManager)에 의해 호출되어 사용자의 정보를 제공하는 역할을 합니다.

package toyproject.springmvcboard.domain.auth2;

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import toyproject.springmvcboard.domain.user.User;
import toyproject.springmvcboard.domain.user.UserRepository;

@Service
@Slf4j
public class PrincipalDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public PrincipalDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    // username을 받아서 UserDetails를 반환하는 메서드
    //함수 종료시 @AuthenticationPrincipal 어노테이션이 생성
    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        //loadUserByUsername의 매개변수 username은 signup 페이지 input의 name 속성에 설정한 이름과 동일해야함
//        User userEntity = userRepository.findByUsername(username);
        User userEntity = userRepository.findByEmail(email);
        log.debug("username = {}", email);
        if (userEntity != null) {
            return new PrincipalDetails(userEntity);
        }
        throw new UsernameNotFoundException("User not found with username: " + email);
    }
}

PrincipalOauth2UserService

PrincipalOauth2UserService는 Spring Security에서 제공하는 OAuth2UserService의 구현체 중 하나로, OAuth 2.0 기반의 로그인을 처리하는 데 사용됩니다. 특히, 외부 OAuth 2.0 제공업체(구글, 페이스북, 네이버 등)로부터 사용자 정보를 가져와서 인증에 활용하는 역할을 합니다.

현재 코드에서는 User의 email 정보를 가지고 회원인지 아닌지 판별해줍니다.

package toyproject.springmvcboard.domain.auth2;

import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import toyproject.springmvcboard.domain.user.User;
import toyproject.springmvcboard.domain.user.UserRepository;

import java.sql.Timestamp;
import java.util.Objects;

@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {


    private final UserRepository userRepository;

    public PrincipalOauth2UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    // 구글로부터 받은 userRequest 데이터에 대한 후처리되는 함수
    // 함수 종료 시  @AuthenticationPrincipal 어노테이션이 생성
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        // 구글 로그인 버튼 클릭 -> 구글 로그인창 -> 로그인 완료 -> 코드 리턴 -> 액세스 토큰 요청
        // userRequest 정보 -> loadUser함수 호출 -> 구글로부터 회원 프로필을 받아옴

        OAuth2User oAuth2User = super.loadUser(userRequest);
        OAuth2UserInfo oAuth2UserInfo = null;
        if (userRequest.getClientRegistration().getRegistrationId().equals("google")) {
            oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes());
        } else {
            System.out.println("Only Google");
        }

        String provider = Objects.requireNonNull(oAuth2UserInfo).getProvider();
        String providerId = oAuth2UserInfo.getProviderId();
        String username = provider + "_" + providerId;
        String password = "비밀번호";
        String email = oAuth2UserInfo.getEmail();
        String role = "RULE_USER";
        Timestamp createDate = new Timestamp(System.currentTimeMillis());

        User userEntity = userRepository.findByUsername(username);

        if (userEntity == null) {
            userEntity = User.builder()
                    .username(username)
                    .password(password)
                    .email(email)
                    .role(role)
                    .provider(provider)
                    .providerId(providerId)
                    .enabled(1)
                    .createDate(createDate)
                    .build();
            userRepository.save(userEntity);
        }

        return new PrincipalDetails(userEntity, oAuth2User.getAttributes());
    }
}

Oauth2UserInfo

구글, 네이버, 카카오등 로그인을 위한 인터페이스를 생성해줍니다.

package toyproject.springmvcboard.domain.auth2;

public interface OAuth2UserInfo {
    String getProviderId();

    String getProvider();

    String getEmail();

    String getUsername();
}

GoogleUserInfo

구글 로그인을 구현체 코드입니다.

package toyproject.springmvcboard.domain.auth2;

import java.util.Map;

public class GoogleUserInfo implements OAuth2UserInfo {

    private Map<String, Object> attributes;

    public GoogleUserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }

    @Override
    public String getProviderId() {
        return attributes.get("sub").toString();
    }

    @Override
    public String getProvider() {
        return "google";
    }

    @Override
    public String getEmail() {
        return attributes.get("email").toString();
    }

    @Override
    public String getUsername() {
        return attributes.get("username").toString();
    }
}

로그인 페이지 생성

spring security에서는 id로 사용할 값의 name을 username으로 지정해줘야합니다.

domain -> auth2 -> LoginController를 생성해줍니다.
template -> account -> signin.html

<!DOCTYPE html>
<html data-bs-theme="auto" lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
    <div th:insert="~{/fragments/header.html :: fragment-temp(로그인)}"></div>
    <style>
        .hr-sect {
            display: flex;
            flex-basis: 100%;
            align-items: center;
            color: rgba(0, 0, 0, 0.35);
            font-size: 20px;
            margin: 8px 0px;
        }
        .hr-sect::before,
        .hr-sect::after {
            content: "";
            flex-grow: 1;
            background: rgba(0, 0, 0, 0.35);
            height: 1px;
            font-size: 0px;
            line-height: 0px;
            margin: 0px 16px;
        }
    </style>
    <!--다크 모드 설정 이미지-->
    <svg th:replace="~{/fragments/darkmode.html :: fragment-image}"></svg>

    <!--다크 모드 버튼-->
    <div th:replace="~{/fragments/darkmode.html :: fragment-button}"></div>
</head>
<body>
<div class="container py-5">
    <div class="row justify-content-center">
        <div class="col-md-6">
            <div class="card shadow-sm">
                <div class="card-body">
                    <h1 class="card-title text-center mb-4 font-weight-bold" style="font-weight: bold">Login</h1>
                    <p class="text-muted text-center">
                        Enter your email below to login to your account
                    </p>
                    <form th:action="@{/account/login}" method="post" class="mt-4">
                        <!-- Email input -->
                        <div class="form-floating mb-3">
                            <input type="email" class="form-control" id="username" name="username" placeholder="m@example.com" required>
                            <label for="username">Email</label>
                        </div>
                        <!-- Password input -->
                        <div class="form-floating mb-3">
                            <input type="password" class="form-control" id="password" name="password" placeholder="Password" required>
                            <label for="password">Password</label>
                        </div>
                        <div th:if="error">
                            <p id="valid" style="color: red; font-size:12px;"><a th:text="${exception}"></a></p>
                        </div>
                        <!-- Submit button -->
                        <button type="submit" class="btn btn-primary btn-lg w-100">Login</button>
                        <div class="hr-sect">or</div>
                        <div class="form-group d-flex justify-content-center">
                            <a th:href="@{/oauth2/authorization/google}" class="btn btn-libtn-lg mt-3">
                                <img class="bi me-2" width="35" height="35" src="/images/web_light_rd_na@2x.png" alt="Google Logo">
                            </a>
                            <a th:href="@{/oauth2/authorization/naver}" class="btn btn-libtn-lg mt-3">
                                <img class="bi me-2" width="35" height="35" src="/images/btnG_아이콘원형.png" alt="Naver Logo">
                            </a>
                        </div>
                        <div style="float: right">
                        <p style="color: gray">Don't have an account?</p>
                        <a th:href="@{/account/signin}" style="float: right">Sign up here</a>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
<footer th:insert="~{/fragments/footer.html :: fragment-footer}"></footer>
</body>
</html>




security를 이용한 로그인시에는 PostMapping을 따로 생성할 필요가 없습니다.
로그인 페이지를 접근하기 위한 GetMapping과 회원가입을 위한 PostMapping을 사용해줍니다.
회원가입 과정에서는 passwordEncoder를 이용하여 비밀번호를 암호화하여 DB에 저장해줍니다.

로그인 완료시 SecurityConfig에서 설정한대로 메인 페이지로 이동하게 됩니다.

Fragment 수정

th:fragment를 지정할 때 head 태그에 직접 쓰지 않고 head 태그 아래 div 태그에 코드를 넣어 사용해주고 th:insert를 사용하면 fragment를 사용하는 html에서 새로운 서식을 지정하여 사용할 수 있습니다.

<div th:insert="~{/fragments/header.html :: fragment-temp(로그인)}"></div>

profile
공부하는 초보 개발자

0개의 댓글