[Spring Boot Tutorial] 9. JDBC 기반 Spring Security 인증&인가

2020. 1. 31. 15:15Spring/Spring Boot Tutorial

300x250
반응형

Spring security 맛보기에서 application.yml 파일에 설정한(인메모리 설정) security user를 이용하여 Spring Security 실습을 했습니다.

그러나, 실제 웹 페이지에서는 한명의 로그인 유저만 이용하지 않습니다.
여러명의 로그인 사용자가 존재하며, 사용자별로 권한도 각각 다를 것입니다.

이에 대한 설정을 Java 클래스 내에 설정한다면 인메모리 설정으로도 여러 사용자를 추가할 수도 있으나, 사용자가 추가될 때마다 코드가 길어지는 인메모리 역시 최선의 방법이 아닐 것입니다.

database에 로그인 정보를 저장하고 이를 이용하여 spring security를 적용하는 방법을 알아봅시다.


  1. spring security 인증에 이용될 엔티티 테이블 생성
    1-1) create ddl을 통해 직접 생성
    1-2) 프로퍼티 설정을 통한 자동 생성
  2. GrantedAuthority 인터페이스를 구현한 UserRole 엔티티 생성
  3. User 엔티티에 UserRole 연관매핑 관계 설정
  4. UserDetails 인터페이스와 User 엔티티를 상속한 SecurityUser 클래스 생성
  5. UserDetailsService 구현 클래스 추가
    5-1) 사용자 조회 메서드 및 사용자 롤 조회 메서드 추가
    5-2) UserDetailsService 구현 클래스 생성
    5-3) 커스터마이징 한 userDetailsService를 JavaConfig에 설정
  6. org.springframework.security.core.userdetails.User -> SecurityUser 변경
    6-1) 컨트롤러 수정
    6-2) AccessDeniedHandler 수정

1. spring security 인증에 이용될 엔티티 테이블 생성

57

이전 과정 에서는 spring security 스타터폼에서 제공해주는 클래스 및 인터페이스를 최대한 활용하여 보안 설정을 했습니다.

그러나, User정보와 UserRoles 정보를 Database에 저장하고 database로부터 값을 읽어들여 보안 적용을 하고 싶다면 위의 class 구조에서 Entity 설정을 추가해야 합니다.

Entity 설정을 한 class를 생성해봅시다.


1) create ddl을 통해 직접 생성

CREATE TABLE `user` (
  `id` INT(11) NOT NULL AUTO_INCREMENT,
  `type` CHAR(1) DEFAULT '0',
  `name` VARCHAR(50) NOT NULL,
  `email` VARCHAR(100) NOT NULL,
  `password` VARCHAR(150) NOT NULL,
  `sex` CHAR(1) NOT NULL DEFAULT '1',
  `phone_number` VARCHAR(20) NOT NULL,
  `birth_date` VARCHAR(6) NOT NULL,
  `create_timestamp` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `update_timestamp` TIMESTAMP NULL DEFAULT NULL,
  `del` TINYINT(1) NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`),
  UNIQUE KEY `UK_USER_EMAIL` (`email`)
);

CREATE TABLE `user_role` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` int(11) NOT NULL,
  `role_name` varchar(20) NOT NULL,
  `create_timestamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
  `update_timestamp` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
  `del` tinyint(1) NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`),
  KEY `FK_USER_ROLE_USER` (`user_id`),
  CONSTRAINT `FK_USER_ROLE_USER` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
);

spring security에 이용될 테이블의 DDL 입니다.
user 테이블은 사용자 정보를 저장하고, user_role 테이블에는 사용자 보유 권한을 저장합니다.


2) 프로퍼티 설정을 통한 자동 생성

참고로, 테이블 생성은 위의 DDL로 직접 생성해도 되지만, application.yml 프로퍼티의 spring.jpa 속성을 이용하여 서버 시작시 자동 생성되도록 할 수 있습니다.

spring:
    jpa:
        properties:
            hibernate:            
                hbm2ddl.auto: update
                format_sql: true
                show_sql: true

ln 5에 설정한 update 설정 추가후 웹 애플리케이션 시작하면 create ddl 이 자동 실행됩니다.

hibernate의 show_sql을 true로 설정하면 실행중인 sql을 콘솔에 출력할 수 있고, format_sql을 true로 설정하면 sql을 컬럼단위로 tab을 넣어 예쁘게 출력할 수 있습니다.

59

spring boot 웹 애플리케이션 시작 시 콘솔에 출력되는 화면


2. GrantedAuthority 인터페이스를 구현한 UserRole 엔티티 생성

Spring security 맛보기 단계에서는 로그인한 사용자를 org.springframework.security.core.userdetails.User 클래스를 이용하여 처리했었습니다.

User 클래스는 Spring security 이용 중에 필수적으로 선언해야할 메서드 선언문을 지닌 UserDetails 라는 인터페이스를 구현한 클래스로, UserDetails 인터페이스에서 선언된 아래에 표시된 6개의 메서드를 구현하고 있습니다. [UML Class Diagram 참고]

  • getAuthorities()
  • getUsername()
  • isAccountNonExpired()
  • isAccountNonLocked()
  • isCredentialsNonExpired()
  • isEnabled()

이 중, 보유 권한을 담는 GrantedAuthority 인터페이스 구현 클래스를 @Entity 클래스로 대체해봅시다.


60

GrantedAuthority 인터페이스를 구현한 UserRole 엔티티 클래스를 생성합니다.
※ BaseEntity 추상클래스는 @MappedSuperclass 설정된 공통 매핑정보를 담은 super class입니다. [참고: @MappedSuperclass로 중복 컬럼 상속화]

@Getter @Setter
@Entity
@Table(name = "user_role", uniqueConstraints = {@UniqueConstraint(columnNames = {"user_id", "role_name"})})
@DynamicUpdate
public class UserRole extends BaseEntity implements GrantedAuthority {

	private static final long serialVersionUID = 7943607393308984161L;

	@JsonBackReference
	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "FK_USER_ROLE_USER"))
	private User user;

	@Column(name="role_name", nullable=false, length = 20)
	@Enumerated(EnumType.STRING)
	private RoleType roleName;

	public enum RoleType {
		ROLE_ADMIN, ROLE_VIEW
	}

	@JsonIgnore
	@Override
	public String getAuthority() {
		return this.roleName.name();
	}

}
  • ln 3: user_id, role_name 컬럼을 이용한 복합 unique index 설정합니다.
  • ln 11: User 엔티티와 N:1 연관관계를 맺는 user 컬럼
  • ln 23~26: GrantedAuthority인터페이스에 선언된 getAuthority 메서드 override

3. User 엔티티에 UserRole 연관매핑 관계 설정

UserRole은 User엔티티의 user_id를 FK로 갖는 N-ary 연관관계를 가지고 있습니다.
(사용자는 권한을 0개 이상 갖습니다.)

61

UML Class Diagram으로 표현하자면 위와 같이 표현할 수 있습니다.


@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter @Setter
@Entity
@Table(name = "user")
@DynamicUpdate @DynamicInsert
public class User extends BaseEntity implements Serializable {

	private static final long serialVersionUID = -563329217866858622L;

	@ColumnDefault(value = "0")
	@Column(nullable = false, length = 1)
	private String type;

	@Column(nullable = false, unique = true, length = 100)
	private String email;

	@Column(nullable = false, length = 50)
	private String name;

	@ColumnDefault(value = "1")
	@Column(nullable = false, length = 1)
	private String sex;

	@Column(nullable = false, length = 6)
	private String birthDate;

	@Column(nullable = false, length = 20)
	private String phoneNumber;

	@JsonIgnore
	@Column(nullable = false, length = 150)
	private String password;

	@Singular("userRoles")
	@JsonIgnoreProperties({"createTimestamp", "updateTimestamp", "del"})
	@JsonManagedReference
	@OneToMany(mappedBy="user")
	@Where(clause = "del = false")
	private Set<UserRole> userRoles;

	@Builder
	public User(String type, String name, String email, String sex, String birthDate, String phoneNumber, String password) {
		this.type = type;
		this.name = name;
		this.email = email;
		this.sex = sex;
		this.birthDate = birthDate;
		this.phoneNumber = phoneNumber;
		this.password = password;
	}

}
  • ln 35: json 조회시 출력에서 제외시킬 컬럼을 설정합니다.
  • ln 36: UserRole과 User 엔티티가 서로를 필드로 가지고 있어, json 조회시 무한 순환참조하는 현상이 발생됩니다. 두 엔티티 중, 매핑필드를 조회하고자 하는 엔티티에 @JsonManagedReference 애너테이션을 선언합니다.
  • ln 37: userRoles에 대해 @OneToMany 연관관계를 설정합니다. 이때, 매핑되는 UserRole엔티티 클래스의 필드는 User user입니다.
  • ln 38: 삭제되지 않은 UserRole만 가져옵니다.(필터 조건 추가)

62

@JsonIgnoreProperties 설정에 의해 출력되는 user의 출력 정보. userRole에서 createTimestamp, updateTimestamp, del 정보는 출력대상에서 제외됩니다.


4. UserDetails 인터페이스와 User 엔티티를 상속한 SecurityUser 클래스 생성

사용자 인증에 이용되었던 org.springframework.security.core.userdetails.User 클래스는 UserDetails 인터페이스의 구현 클래스였습니다.

UserDetails는 Spring Security에서 인증/인가에 이용되는 필수 메서드를 담은 인터페이스
라고 위에서 설명을 드렸었는데,
만약 우리가 생성한 User 엔티티 클래스를 Spring Security 인증/인가처리를 하고 싶다면 어떻게 해야 할까요?

간단합니다. org.springframework.security.core.userdetails.User 클래스와 같이 UserDetails 인터페이스를 상속받아 필수 메서드들을 구현하면 됩니다.

User 클래스는 로그인 처리 이외에도, api에도 이용될 예정이니 보안 인증/인가용으로 이용된 User 클래스를 별도로 만드는게 더 효율적으로 보입니다.


58

위의 UML Class Diagram을 보면 명확히 알 수 있을것입니다.
3번과정에서 생성했던 User 엔티티클래스와 UserDetails 인터페이스를 상속받는 SecurityUser 클래스를 생성합니다.

public class SecurityUser extends User implements UserDetails {

	private static final long serialVersionUID = 8666468119299100306L;

	private final boolean accountNonExpired;
	private final boolean accountNonLocked;
	private final boolean credentialsNonExpired;
	private final boolean enabled;

	public SecurityUser(User user) {
		super();
		setId(user.getId());
		setEmail(user.getEmail());
		setName(user.getName());
		setPassword(user.getPassword());
		setDel(user.isDel());
		setUserRoles(user.getUserRoles());
		this.accountNonExpired = true;
		this.accountNonLocked = true;
		this.credentialsNonExpired = true;
		this.enabled = true;
	}

	public Set<RoleType> getRoleTypes() {
		return getUserRoles().stream().map(f -> f.getRoleName()).collect(Collectors.toSet());
	}

	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		return getUserRoles();
	}

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

	@Override
	public boolean isAccountNonExpired() {
		return this.accountNonExpired;
	}

	@Override
	public boolean isAccountNonLocked() {
		return this.accountNonLocked;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		return this.credentialsNonExpired;
	}

	@Override
	public boolean isEnabled() {
		return this.enabled;
	}

}
  • ln 5~8, 18~21: 인증 및 인가에 이용되는 필드값. 생성자 초기화시 함께 초기화 합니다. (4중 하나라도 false일 경우 로그인 거부됩니다.)
  • ln 28~56: UserDetails에서 선언된 메서드 구현
    • 확인순서: isAccountNonLocked -> isEnabled -> isAccountNonExpired -> isCredentialsNonExpired

5. UserDetailsService 구현 클래스 추가

63

Spring security에서는 UserDetailsService 구현 클래스에 정의된 loadUserByUsername() 메서드를 이용하여 사용자 인증 처리합니다.

그 중, application.yml에 설정한 security user의 경우 인메모리 설정의 일종으로, InMemoryUserDetailsManager 구현클래스에 정의된 메서드를 이용하여 로그인 인증처리를 합니다.

우리가 직접 생성한 SecurityUser를 이용하여 인증처리를 하고 싶다면, User 엔티티를 이용하여 DB에서 정보를 조회한 후 SecurityUser로 반환하는 과정을 정의하는 UserDetailsService 구현 클래스를 새로 생성해야 합니다.

64

UserDetailsService를 구현하는 서비스를 생성해봅시다.
서비스 생성 이전에, 사용자 및 사용자 보유권한 조회를 위해 사용자 조회 메서드를 추가해야 합니다.


1) 사용자 조회 메서드 및 사용자 롤 조회 메서드 추가

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
	...

    @EntityGraph(attributePaths = "userRoles")
    Optional<User> findWithUserRolesByEmailAndDel(String email, boolean del);
}

사용자와 사용자의 보유권한을 조회하는 메서드를 추가합니다.
사용자 보유 권한을 바로 가져오기 위해 @EntityGraph 설정을 추가했습니다.
@EntityGraph 설정을 할 경우, left Outer join으로 보유권한 리스트를 바로 가져옵니다.(이 설정을 하지 않을 경우, Lazy 설정으로 인해 getter 이용시 한번 더 조회됩니다.)

※ 로그인을 username 대신 email 을 이용하여 조회할 예정이므로 위와 같이 Email로 User를 조회합니다.


2) UserDetailsService 구현 클래스 생성

@Service
public class SecurityUserService implements UserDetailsService {

	private Logger logger = LoggerFactory.getLogger(SecurityUserService.class);

	private final UserRepository userRepository;

	@Autowired
	public SecurityUserService(UserRepository userRepository) {
		this.userRepository = userRepository;
	}

	@Override
	public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
		Optional<User> oUser = userRepository.findWithUserRolesByEmailAndDel(email, false);
		if(!oUser.isPresent()) {
			logger.info("존재하지 않는 아이디입니다: " + email);
			throw new UsernameNotFoundException(email);
		}
		return new SecurityUser(oUser.get());
	}
}

UserDetailsService 를 구현하는 클래스를 생성합니다.
UserDetailsService에 선언된 loadUserByUsername을 구현하는데, 우리는 username대신 email을 이용하여 로그인을 할 것이기 때문에,
ln 15과 같이 email을 통해 db로부터 사용자를 조회합니다.

user가 존재할 경우, user를 이용하여 SecurityUser 객체를 생성하여 반환합니다.


3) 커스터마이징 한 userDetailsService를 JavaConfig에 설정

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final SecurityUserService securityUserService;
    private final WebAccessDeniedHandler webAccessDeniedHandler;

    @Autowired
    public WebSecurityConfig(SecurityUserService securityUserService, WebAccessDeniedHandler webAccessDeniedHandler) {
        this.securityUserService = securityUserService;
        this.webAccessDeniedHandler = webAccessDeniedHandler;
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/static/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/", "/login", "/join", "/test/**").permitAll()
            .antMatchers("/v/users").access("hasRole('ROLE_ADMIN')")
            .antMatchers("/v", "/v/**").access("hasRole('ROLE_VIEW')")
            .anyRequest().authenticated()
        .and()
            .formLogin().loginPage("/login").defaultSuccessUrl("/v", true)
            .usernameParameter("email").passwordParameter("password")
        .and()
            .logout().invalidateHttpSession(true).deleteCookies("JSESSIONID")
        .and().exceptionHandling().accessDeniedHandler(webAccessDeniedHandler)		
        .and()
            .authenticationProvider(authenticationProvider())
        .csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
    }

    @Bean
    public DaoAuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(securityUserService);
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

인증 처리에 이용되는 authenticationProvider를 커스터마이징 합니다.
로그인 성공시 SecurityUser를 반환하는 기능을 정의한 securityUserService를 여기에서 정의하며(ln 40)
로그인 인증시 자동으로 입력된 비밀번호 값을 인코딩하도록 passwordEncoder도 설정합니다.

userDetailsService 설정할 경우, 비밀번호 암호화를 반드시 설정해야 하고
이 passwordEncoder는 추후 비밀번호 교체 등 다른 기타 메서드에도 이용될 수 있기 때문에 따로 bean으로 생성했습니다.

※ WebSecurity Config 관련 설정에 관한 자세한 사항은 Spring Security API를 참고해주세요.


65

passwordEncoder 미설정시 출력되는 에러 로그



66

PasswordEncoder를 사용하고자 하는 암호화 알고리즘에 맞게 설정하면 됩니다.
Blowfish 암호 기반의 암호 해싱 알고리즘인 bcrypt로 설정했습니다.
(만일 암호화 알고리즘을 사용하고 싶지 않다면, NoOpPasswordEncoder.getInstance()로 passwordEncoder 반환값을 설정하면 됩니다.)


6. User, SimpleGrantedAuthority -> SecurityUser, RoleType 변경

org.springframework.security.core.userdetails.User 를 이용하여 인가처리하던 부분을 SecurityUser로 교체하는 작업이 필요합니다. (securityUser 인스턴스가 보유하고 있는 권한을 이용하여 인가처리하기 위해)


6-1) 컨트롤러 수정

변경 전
@GetMapping(value = "/")
public String index(@AuthenticationPrincipal User user){
    if(user != null) {
        if(user.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_VIEW"))) {
            return "redirect:/v";
        }
    }
    return "redirect:/login";
}

위와같이 User 클래스와 SimpleGrantedAuthority 클래스를 이용하여 권한 보유 유무에 따라 페이지 이동을 시키던 부분을 [이전 코드 참고]


변경 후
@GetMapping(value = "/")
public String index(@AuthenticationPrincipal SecurityUser securityUser){
    if(securityUser != null) {
        if(securityUser.getRoleTypes().contains(RoleType.ROLE_VIEW)) {
            return "redirect:/v";
        }
    }
    return "redirect:/login";
}

SecurityUser 와 RoleType로 교체합니다.


6-2) AccessDeniedHandler 수정

변경 전
@Component
public class WebAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest req, HttpServletResponse res, AccessDeniedException ade)
        throws IOException, ServletException {
            res.setStatus(HttpStatus.FORBIDDEN.value());
            if(ade instanceof AccessDeniedException) {
                Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
                if (authentication != null &&
                    ((User) authentication.getPrincipal()).getAuthorities().contains(new SimpleGrantedAuthority("ROLE_VIEW"))) {
                req.setAttribute("msg", "접근권한 없는 사용자입니다.");
                req.setAttribute("nextPage", "/v");
            }
            ...
        }
        ...
    }
}

마찬가지로 User, SimpleGrantedAuthority를 이용하던 부분을 [이전 코드 참고]


변경 후
@Component
public class WebAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest req, HttpServletResponse res, AccessDeniedException ade)
    throws IOException, ServletException {
        res.setStatus(HttpStatus.FORBIDDEN.value());

        if(ade instanceof AccessDeniedException) {
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            if (authentication != null) {
                SecurityUser securityUser = (SecurityUser) authentication.getPrincipal();
                Set<RoleType> roleTypes = securityUser.getRoleTypes();
                if(!roleTypes.isEmpty()) {
                    req.setAttribute("msg", "접근권한 없는 사용자입니다.");
                    if (roleTypes.contains(RoleType.ROLE_VIEW)) {
                        req.setAttribute("nextPage", "/v");
                    }
                }
                ...
            }
        }
        ...
    }
}

SecurityUser 와 RoleType로 교체합니다.


GitHub에서 demo 프로젝트를 다운받아 볼 수 있습니다.


++ tip

문자열을 passwordEncoder로 인코딩한 값을 알고 싶다면, passwordEncoder bean을 @Autowired로 가져와서
문자열을 encode 하면 됩니다.

@RequestMapping(value = "/test", produces = {MediaType.APPLICATION_JSON_VALUE})
@RestController
public class TestController {

	private final StoreService storeService;
	private final UserService userService;
	private final PasswordEncoder passwordEncoder;

	@Autowired
	public TestController(StoreService storeService, UserService userService,
			PasswordEncoder passwordEncoder) {
		this.storeService = storeService;
		this.userService = userService;
		this.passwordEncoder = passwordEncoder;
	}

	...

	@GetMapping("/encode/{password}")
	public String selectStore(@PathVariable("password") String password) {
		return passwordEncoder.encode(password);
	}
}

67

조회 결과

300x250
반응형