Spring Boot Oauth2 Client Server

By | 2021년 7월 24일
Table of Contents

Spring Boot Oauth2 Client Server

참조사이트 : https://yeonyeon.tistory.com/34?category=920206

참조사이트 : https://www.skyer9.pe.kr/wordpress/?p=205

목표

추가되는 소스코드의 양을 분산시키기 위해 아래와 같이 3단계로 구성합니다.

  1. 단순 로그인 기능(아이디/패스워드 기반)을 구현합니다.

  2. 구글 Oauth2 로그인을 연동합니다.

  3. 자체 구축한 Oauth2 Authorization Server 와 연동합니다.

소스코드

여기 에 전체 소스코드가 올라가 있습니다.

hosts 파일 수정

클라이언트/리소스서버/인증서버 3개의 서버에 각각 다른 도메인을 부여합니다.

127.0.0.1    auth.localhost
127.0.0.1    res.localhost

단순 로그인 기능 구현

프로젝트 생성

신규 프로젝트를 생성합니다.

프로젝트명은 oauth2client 로 합니다.

의존성은 DevTools, Lombok, Spring Data JPA, MySQL, Oauth2 Client, Spring Web, Thymeleaf 을 선택합니다.

테이블 생성

DROP TABLE `user`;

CREATE TABLE `user` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT,
    `email` varchar(255) NOT NULL,
    `username` varchar(255) NOT NULL,
    `picture` varchar(255) DEFAULT NULL,
    `role` varchar(255) NOT NULL,
    `password` varchar(255) NULL,
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

INSERT INTO `user`(email, username, role, password)
VALUES('test@gmail.com', 'user', 'USER', '{noop}pass');
INSERT INTO `user`(email, username, role, password)
VALUES('admin@gmail.com', 'admin', 'ADMIN', '{noop}pass');

application.yml 수정

spring:
  datasource:
    url: jdbc:mysql://${MYSQL_HOST:localhost}:3306/db_oauth2
    username: root
    password: abcd1234
    driver-class-name: com.mysql.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: none
    show-sql: false

엔터티 클래스 생성

Role.java

@Getter
@RequiredArgsConstructor
public enum Role {

    GUEST("ROLE_GUEST", "손님"),
    USER("ROLE_USER", "일반 사용자"),
    ADMIN("ROLE_ADMIN", "관리자");

    private final String key;
    private final String title;
}

User.java

@Getter
@NoArgsConstructor
@Entity
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @Column(nullable = false)
    private String username;

    @Column(nullable = false)
    private String email;

    @Column
    private String picture;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    @Column
    private String password;

    @Builder
    public User(String username, String email, String picture, Role role, String password) {
        this.username = username;
        this.email = email;
        this.picture = picture;
        this.role = role;
        this.password = password;
    }

    public User update(String name, String email, String picture) {
        this.username = name;
        this.email = email;
        this.picture = picture;

        return this;
    }

    public String getRoleKey() {
        return this.role.getKey();
    }
}

UserRepository.java

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByUsername(String username);
}

서비스 클래스 생성

UserService.java

@Service
@RequiredArgsConstructor
public class UserService implements UserDetailsService {

    private final UserRepository userRepository;
    private final HttpSession httpSession;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<User> userWrapper = userRepository.findByUsername(username);

        if(userWrapper.isEmpty()) {
            throw new UsernameNotFoundException("Username not found");
        }

        User user = userWrapper.get();

        List<GrantedAuthority> authorities = new ArrayList<>();
        httpSession.setAttribute("user", new SessionUserDto(user));

        if (("admin").equals(username)) {
            authorities.add(new SimpleGrantedAuthority(Role.ADMIN.getKey()));
        } else {
            authorities.add(new SimpleGrantedAuthority(Role.USER.getKey()));
        }

        return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), authorities);
    }
}

Spring Security 설정

auth.userDetailsService(userService) 를 설정해서,
JDBC 상의 사용자정보로 로그인하도록 설정됩니다.

SecurityConfig.java

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final UserService userService;

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.csrf().disable()
                .headers().frameOptions().disable()
                .and()
                    .authorizeRequests()
                    .antMatchers("/", "/css/**", "/images/**", "/js/**", "/login", "/profile").permitAll()
                    .antMatchers("/api/v1/**", "/posts/save").hasRole(Role.USER.name())
                    .anyRequest().authenticated()
                .and()
                    .logout()
                    .logoutSuccessUrl("/")
                .and()
                    .formLogin();
    }

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userService);
    }
}

세션정보

Entity 에 생성된 사용자정보를 Web 에서 사용하기 위해,
DTO 클래스를 생성해줍니다.

SessionUserDto.java

@Getter
public class SessionUserDto implements Serializable {

    private String username;
    private String email;
    private String picture;

    public SessionUserDto(User user) {
        this.username = user.getUsername();
        this.email = user.getEmail();
        this.picture = user.getPicture();
    }
}

컨트롤러

IndexController.java

@RequiredArgsConstructor
@Controller
public class IndexController {

    private final HttpSession httpSession;

    @GetMapping("/")
    public String index(Model model, @PageableDefault Pageable pageable, @RequestParam Map<String, String> params) {

        SessionUserDto sessionUserDto = (SessionUserDto) httpSession.getAttribute("user");
        if (sessionUserDto != null) {
            model.addAttribute("userName", sessionUserDto.getUsername());
        }

        return "index";
    }
}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div class="col-md-12">
    <div class="row">
        <div class="col-md-6">
            <div th:if="${userName != null}" th:inline="text">
                [[${userName}]] 님, 안녕하세요.
                <a href="/logout" class="btn btn-info active" role="button">로그아웃</a>
            </div>
            <div th:if="${userName == null}">
                <a href="/login" class="btn btn-success active" role="button">로그인</a>
            </div>
        </div>
    </div>
</div>
</body>
</html>

프로젝트 실행

프로젝트를 실행합니다.
http://localhost:8080/ 에 접속하면 로그인 링크가 표시되는 것을 확인할 수 있습니다.

로그인 링크를 클릭하면 로그인화면이 표시됩니다.
로그인을 하면 로그인한 username 이 표시됩니다.

로그아웃 링크를 클릭해서 로그아웃할 수 있습니다.

세션정보 어노테이션으로 가져오기

세션에서 DTO 클래스로 매핑해 주는 과정을 어노테이션 으로 단순화 해줍니다.

LoginUser.java

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}

LoginUserArgumentResolver.java

@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    private final HttpSession httpSession;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean isLoginUserAnnotation = parameter.getParameterAnnotation(LoginUser.class) != null;
        boolean isUserClass = SessionUserDto.class.equals(parameter.getParameterType());

        return isLoginUserAnnotation && isUserClass;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return httpSession.getAttribute("user");
    }
}

WebMvcConfig.java

@RequiredArgsConstructor
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    private final LoginUserArgumentResolver loginUserArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(loginUserArgumentResolver);
    }
}

@LoginUser SessionUserDto user 와 같이,
사용자 정보가 DTO 클래스에 자동할당 됩니다.

IndexController.java

@RequiredArgsConstructor
@Controller
public class IndexController {

    private final HttpSession httpSession;

    @GetMapping("/")
    public String index(Model model, @PageableDefault Pageable pageable, @LoginUser SessionUserDto user, @RequestParam Map<String, String> params) {

        if (user != null) {
            model.addAttribute("userName", user.getUsername());
        }

        return "index";
    }
}

로그인 정보가 파라미터로 전달되는 것을 확인할 수 있습니다.

구글 로그인 연동

클라이언트 아이디 생성

여기 를 참조하여 클라이언트 아이디를 생성합니다.

application-oauth.yml 생성

google/facebook 등의 유명한 인증서버에 대해서는,
스프링 부트에서 공통설정을 미리 해두었기에,
클라이언트 설정만 해줌으로 모든 설정이 끝납니다.

application-oauth.yml

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: XXXXXXXXXX
            client-secret: XXXXXXXXXXXXX

application.yml 수정

profiles 를 설정함으로 application-oauth.yml 이 인식됩니다.

application.yml

spring:
  datasource:
    url: jdbc:mysql://${MYSQL_HOST:localhost}:3306/db_oauth2
    username: root
    password: abcd1234
    driver-class-name: com.mysql.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: none
    show-sql: false
  profiles:
    include: oauth

.gitignore 수정

설정파일이 git 에 올라가지 않도록 설정합니다.

application.yml
application-oauth.yml

엔터티 클래스 수정

password 필드를 제거합니다.

User.java

@Getter
@NoArgsConstructor
@Entity
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @Column(nullable = false)
    private String username;

    @Column(nullable = false)
    private String email;

    @Column
    private String picture;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    @Builder
    public User(String username, String email, String picture, Role role) {
        this.username = username;
        this.email = email;
        this.picture = picture;
        this.role = role;
    }

    public User update(String name, String picture) {
        this.username = name;
        this.picture = picture;

        return this;
    }

    public String getRoleKey() {
        return this.role.getKey();
    }
}

Spring Security 설정

UserService.java 제거

OAuthAttributes.java

@Getter
public class OAuthAttributes {
    private Map<String, Object> attributes;
    private String nameAttributeKey;
    private String username;
    private String email;
    private String picture;

    @Builder
    public OAuthAttributes(Map<String, Object> attributes,
                           String nameAttributeKey, String username,
                           String email, String picture) {
        this.attributes = attributes;
        this.nameAttributeKey = nameAttributeKey;
        this.username = username;
        this.email = email;
        this.picture = picture;
    }

    public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
        return OAuthAttributes.builder()
                .username((String) attributes.get("name"))
                .email((String) attributes.get("email"))
                .picture((String) attributes.get("picture"))
                .attributes(attributes)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }

    public User toEntity() {
        return User.builder()
                .username(username)
                .email(email)
                .picture(picture)
                .role(Role.GUEST)
                .build();
    }
}

delegate.loadUser 에 의해,
user-info-uri 에서 제공하는 사용자 정보를 받습니다.

받아온 사용자 정보를 자체 디비에 저장할 수 있습니다.

CustomOAuth2UserService.java

@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<OAuth2UserRequest, OAuth2User> 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 SessionUserDto(user));

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
                attributes.getAttributes(),
                attributes.getNameAttributeKey());
    }

    private User saveOrUpdate(OAuthAttributes attributes) {
        User user = userRepository.findByUsername(attributes.getUsername())
                .map(entity-> entity.update(attributes.getUsername(), attributes.getEmail(), attributes.getPicture()))
                .orElse(attributes.toEntity());

        return userRepository.save(user);
    }
}

SecurityConfig.java

@Configuration
@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/**","/profile").permitAll()
                    .antMatchers("/api/v1/**").hasRole(Role.USER.name())
                    .anyRequest().authenticated()
                .and()
                    .logout().logoutSuccessUrl("/")
                .and()
                    .oauth2Login()
                    .userInfoEndpoint()
                    .userService(customOAuth2UserService);
    }
}

프로젝트 실행

프로젝트를 실행합니다.
http://localhost:8080/ 에 접속하면 로그인 링크가 표시되는 것을 확인할 수 있습니다.

로그인 링크를 클릭하면 로그인화면이 표시됩니다.
로그인을 하면 로그인한 username 이 표시됩니다.

로그아웃 링크를 클릭해서 로그아웃할 수 있습니다.

자체 인증서버 연동

콜백 URI 수정

UPDATE oauth_client_details
SET
    web_server_redirect_uri = 'http://localhost:8080/login/oauth2/code/local',
    scope = 'profile,email'
WHERE client_id = 'foo';

application-oauth.yml 수정

클라이언트 정보 local 을 설정해 줍니다.

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: XXXXXXXXXX
            client-secret: XXXXXXXXXXXXX
            scope: profile, email
          local:
            client-id: foo
            client-secret: bar
            scope: profile, email
            redirect-uri: http://localhost:8080/login/oauth2/code/local
            authorization-grant-type: authorization_code
            client-name: Local
        provider:
          local:
            authorization-uri: http://auth.localhost:9000/oauth/authorize
            token-uri: http://auth.localhost:9000/oauth/token
            user-info-uri: http://res.localhost:9001/api/userinfo
            user-name-attribute: name

프로젝트 실행

프로젝트를 실행합니다.
http://localhost2:8080/ 에 접속하면 로그인 링크가 표시되는 것을 확인할 수 있습니다.

로그인 링크를 클릭하면 로그인화면이 표시됩니다.
로그인을 하면 로그인한 username 이 표시됩니다.

로그아웃 링크를 클릭해서 로그아웃할 수 있습니다.

One thought on “Spring Boot Oauth2 Client Server

  1. Pingback: Spring Boot 시작하기 – 상구리의 기술 블로그

답글 남기기