Spring Boot Oauth2 Client Server
참조사이트 : https://yeonyeon.tistory.com/34?category=920206
참조사이트 : https://www.skyer9.pe.kr/wordpress/?p=205
목표
추가되는 소스코드의 양을 분산시키기 위해 아래와 같이 3단계로 구성합니다.
-
단순 로그인 기능(아이디/패스워드 기반)을 구현합니다.
-
구글 Oauth2 로그인을 연동합니다.
-
자체 구축한 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 이 표시됩니다.
로그아웃 링크를 클릭해서 로그아웃할 수 있습니다.
Pingback: Spring Boot 시작하기 – 상구리의 기술 블로그