source: https://github.com/ziponia/spring-security-example
이번에는 Security Principal 객체를 커스터마이징 해보자.
기존에 로그인 하게 되면 기본적으로 spring security context
package org.springframework.security.core.userdetails.UserDetails; 를 구현하는것이며,
가장 첫 포스트에서 만든 security context 객체 는
Security 의 UserDetails 를 사용하기 쉽게 미리 만들어 둔, UserDetails 의 구현 객체인 User org.springframework.security.core.userdetails.User
객체이다.
이 User 객체를 살펴보게 되면 다음과 같이 생성자가 두개 미리 생성되어있다.
public class User implements UserDetails, CredentialsContainer {
public User(String username, String password,
Collection<? extends GrantedAuthority> authorities) {
// ...
}
public User(String username, String password, boolean enabled,
boolean accountNonExpired, boolean credentialsNonExpired,
boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {
// ...
}
}
하나는 단순하게 아이디, 비밀번호, 권한을 생성하는 컨스트럭터이고,
다른 하나는 아이디, 비밀번호, 사용가능여부, 만료여부, 비밀번호가 만려되었는지, 잠겨있는지, 권한 을 생성하는 객체이다.
아이디, 비밀번호, 권한 을 제외하고는 설정하지 않으면 기본적으로 true 이다.
그런데, 왼지 컨텍스트에 위에 정보들만 가지고 다니기 좀 꺼림칙 하다.
예를들면, 우리의 서비스는 임대형이고, 각 페이지를 돌아다니면서 각각 사용자의 맞는 리소스를 가지고 오려면 위의 컨텍스트 객체의 username 을 가지고 온 다음,
DB 에서 username 에 대한 유저 객체를 조회하고, 조회한 유저 객체가 속한 그룹 정보를 가지고 와야 한다.
더 좋은 방법도 있겠지만, 설득을 위해 조금 억지를 부려 보았다. 납득하자.
이걸 security context 에서 바로 해당 유저의 그룹 정보가 ‘미리’ 담겨 있다면, 왼지 더 심플 해 질 것 같다.
예시를 보자.
먼저 사용자가 속한 그룹을 만들자.
GroupEntity.java
package ziponia.spring.security;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import javax.persistence.*;
import java.util.Collection;
import java.util.Date;
@Entity
@Getter
@Setter
@Table(name = "tbl_group")
public class GroupEntity {
@Id
@GeneratedValue
private Integer idx;
private String groupName;
@OneToMany(mappedBy = "group")
private Collection<UserEntity> users;
@CreationTimestamp
private Date createTime;
@UpdateTimestamp
private Date updateTime;
}
그리고 tbl_user 에 group 을 추가하자.
UserEntity.java
public class UserEntity {
// ...
@ManyToOne(fetch = FetchType.LAZY)
private GroupEntity group;
}
GroupEntity 와 UserEntity 는 1:n 양방향 관계로 설정하고, LAZY 로딩을 채택 하였다.
이제 import.sql 구문에 기본 group 을 만들고, user 테이블에 해당 그룹을 설정하겠다.
import.sql
insert into tbl_group (idx, group_name, create_time) values (1, '제프컴퍼니', current_timestamp());
insert into tbl_users (idx, username, password, nick_name, group_idx) values (1, 'user', '{bcrypt}$2a$10$Wyc.IrbO.bqraF58565Yde6J6heWdARvbDUKfaQYr9v/IoHcQ1RlK', '제프', 1);
insert into tbl_users (idx, username, password, nick_name, group_idx) values (2, 'admin', '{bcrypt}$2a$10$Wyc.IrbO.bqraF58565Yde6J6heWdARvbDUKfaQYr9v/IoHcQ1RlK', '블로그 관리자', 1);
insert into tbl_oauth_client (idx, authorities, auto_approve, client_id, client_secret, grant_types, redirect_uri, scope, user_entity_idx) values (1, 'CLIENT', false, 'client', '{bcrypt}$2a$10$cxEU57mmmEm9FfhAJBMW7ec9oG4Y5Uq4Os8CfpxoL6TLzxPCCqzXK', 'client_credentials,authorization_code,refresh_token,password', 'http://localhost:4000/api/callback', 'read,basic,profile', 1);
tbl_group 에 1번 레코드에 ‘제프컴퍼니’ 라는 그룹을 추가하고, user 계정 group 에 제프컴퍼니를 추가 해 주었다.
스크립트 상으로, tbl_group 이 tbl_user 보다 먼저 올라와야 한다.
이제 UserController 에서 유저 그룹 정보를 가지고 오는 GET /profile/group
를 만들고, 그룹정보를 가지고 오자.
UserController.java
public class UserController {
//...
@PersistenceContext
private EntityManager em; // JPA
@GetMapping(value = "/profile/group")
@PreAuthorize("isAuthenticated()")
public String myGroup(
@AuthenticationPrincipal Authentication authentication,
Model model
) {
// user 정보를 가지고 온다.
UserEntity user = em.createQuery("select u from UserEntity u where u.username = :username", UserEntity.class)
.setParameter("username", authentication.getName()).getSingleResult();
// group 정보를 가지고 온다.
GroupEntity group = em.createQuery("select g from GroupEntity g where g.idx = :idx", GroupEntity.class)
.setParameter("idx", user.getGroup().getIdx()).getSingleResult();
model.addAttribute("group", group);
return "user_group";
}
}
이제 user_group 페이지를 만들고 네비게이션 메뉴에 추가하자.
templates/user_group.html
<template
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorator="layout/layout"
th:remove="tag"
>
<th:block layout:fragment="contents">
<h1>제 그룹은 [[ ${group.groupName} ]] 입니다.</h1>
</th:block>
</template>
templates/layout/navigation.html
<template
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
th:remove="tag"
>
<th:block layout:fragment="navigation">
<nav class="navbar navbar-default">
<ul class="nav navbar-nav">
<li><a th:href="@{/}">홈</a></li>
<li><a th:href="@{/admin}">관리자 페이지</a></li>
<li><a th:href="@{/private}">개인 페이지</a></li>
<li><a th:href="@{/profile}">유저 검색 페이지</a></li>
<!-- new -->
<li><a th:href="@{/profile/group}">내 그룹 관리</a></li>
<li>
<a href="javascript: void(0);" onclick="document.getElementById('logout-form').submit()">
로그아웃
</a>
</li>
</ul>
</nav>
<form id="logout-form" th:action="@{/logout}" method="post"></form>
</th:block>
</template>
이제 http://localhost:8080/profile/group 로 들어 가 보면 아래 페이지를 볼 수 있을 것이다.
근데 문제가 있다.
컨트롤러를 살펴 보면
// user 정보를 가지고 온다.
UserEntity user = em.createQuery("select u from UserEntity u where u.username = :username", UserEntity.class)
.setParameter("username", authentication.getName()).getSingleResult();
// group 정보를 가지고 온다.
GroupEntity group = em.createQuery("select g from GroupEntity g where g.idx = :idx", GroupEntity.class)
.setParameter("idx", user.getGroup().getIdx()).getSingleResult();
user 정보를 가져와, 유저가 가지고 있는 group의 fk 값을 조회하고, 조회 한 fk 값으로 group 정보를 조회하는 쿼리가 2개 인 것이다.
이 부분을 개선하기 위해, SecurityContext 에 저장 될 Context 객체를 수정 해보자.
먼저 Security 의 User 객체를 상속 할 CustomUserDetail 객체를 만들자.
CustomUserDetail.java
package ziponia.spring.security;
public class CustomUserDetail {
}
그리고 이 객체는 User 객체를 상속한다.
public class CustomUserDetail extends User {
public CustomUserDetail(String username, String password, Collection<? extends GrantedAuthority> authorities) {
super(username, password, authorities);
}
}
Security User 의 username 과 password 와 authorities 만 가지고 있는 컨스트럭터를 상속하였다.
그리고 group 아이디 필드를 만들자
public class CustomUserDetail extends User {
private Integer groupIdx;
public CustomUserDetail(String username, String password, Collection<? extends GrantedAuthority> authorities, Integer groupIdx) {
super(username, password, authorities);
this.groupIdx = groupIdx;
}
public Integer getGroupIdx() {
return groupIdx;
}
}
컨스트럭터의 4번째 인자값으로, groupIdx 를 넣고, 멤버변수에 groupIdx 로 집어 넣어 준 다음, getter 를 만들었다.
이제 UserDetailsServiceImpl 에서 new User(…) 를 우리가 만든 CustomUserDetail 로 변경하자.
UserDetailsServiceImpl.java
public class UserDetailsServiceImpl implements UserDetailsService {
// ... some
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
// ... some
return new CustomUserDetail(userEntity.getUsername(), userEntity.getPassword(), authorities, userEntity.getGroup().getIdx());
}
}
마지막으로 UserController 에서 /profile/group 에 들어있는 Authentication 객체를 CustomUserDetail 로 변경 하고, 기존에 userEntity 를 가지고 오는 객체를 주석 처리 해주면, 쿼리 한번으로 그룹을 조회 할 수 있다.
UserController.java
public class UserController {
// ...
@GetMapping(value = "/profile/group")
@PreAuthorize("isAuthenticated()")
public String myGroup(
// Authentication -> CustomUserDetail
@AuthenticationPrincipal CustomUserDetail authentication
Model model
) {
// user 정보를 가지고 온다.
/*UserEntity user = em.createQuery("select u from UserEntity u where u.username = :username", UserEntity.class)
.setParameter("username", authentication.getName()).getSingleResult();*/
// group 정보를 가지고 온다.
GroupEntity group = em.createQuery("select g from GroupEntity g where g.idx = :idx", GroupEntity.class)
.setParameter("idx", authentication.getGroupIdx()).getSingleResult();
model.addAttribute("group", group);
return "user_group";
}
}
만약에 서버 부팅 후 에러가 난다면, 홈으로 가서 로그아웃 후 다시 접속 하자. SecurityContext 에 groupIdx 가 담기지 안은 상태로, 컨텍스트 객체가 만들어 지지 않아서 생기는 문제이다.
이렇게 SecurityContext 객체를 입맛대로 수정 해 보았다.
이제 입맛대로 다른 정보들도 관리 해보자.