programming study/B-Spring Security

Spring Security 사용하기

gu9gu 2022. 12. 27. 01:31

필요한 의존성

	<dependencies>
    	<!-- 시큐리티 태그 라이브러리 -->
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-taglibs</artifactId>
		</dependency>
        
		<dependency>
   			<groupId>org.springframework.boot</groupId>
   			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>
	</dependencies>

스프링 시큐리티 로그인

localhost의 어느 url로 요청하든지 spring security가 가로채서 로그인창이 있는 화면을 띄운다.

id는 user

password는 spring security가 만든 password를 사용한다.

spring을 run하면 log에 패스워드를 보여준다.

Using generated security password: ㅁㅁㅁㅁㅁㅁㅁㅁㅁㅁㅁㅁㅁㅁㅁㅁㅁ

This generated password is for development use only. Your security configuration must be updated before running your application in production.

jsp에서 Spring Security Taglib 사용하기

참고 : Introduction to Spring Security Taglibs | Baeldung

 

1. 의존성 추가

<dependency>

   <groupId>org.springframework.security</groupId>

   <artifactId>spring-security-taglibs</artifactId>

   <version>5.2.2.RELEASE</version>

</dependency>

 

2. jsp에서 import추가

<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %>

 

 

3.로그인 확인

* 로그아웃 url  ->  http://localhost:포트/logout    ( /logout 요청을 보내기만 하면 로그아웃된다. 직접 구현하지 않아도 스프링시큐리티에서 로그아웃이 구현되어있다.)

* 로그인 url ->   http://localhost:포트/login

 

<sec:authorize access="!isAuthenticated()">

Login

</sec:authorize>

<sec:authorize access="isAuthenticated()">

Logout

</sec:authorize>

 


principal 프로퍼티를 변수(var) principal에 담아서 사용하는 방법

 - jsp에서 sec property의 principal 은 스프링 시큐리티에서 설정한 userDetails를 상속받은 객체를 뜻한다.

 - userDetails 설정 방법

   ( 자세한 내용은 아래 'Spring Security 기본 설정 방법' 코드 참고)

   1) 'userDetails를 상속받은 객체'를 'UserDetailsService를 상속 받아 오버라이딩한 loadUserByUsername 메서드'에서 반환시킨다.

   2) UserDetailsService를 상속받은 객체를 WebSecurityConfigurerAdapter를  상속 받아 오버라이딩 한 configure(AuthenticationManagerBuilder auth) 메서드에서 auth.userDetailsService(principalDetailService)로 설정한다.

<sec:authorize access="isAuthenticated()">
   <sec:authentication property="principal" var="principal"/>
</sec:authorize>

 


 

Spring Security 기본 설정 방법

 - SecurityConfig, PrincipalDetailService, PrincipalDetail 클래스를 만든다.

package com.jig.blog.config.auth;

import com.jig.blog.model.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

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

/**
 * 스프링 시큐리티에서 사용하는 User객체에 직접 만든 User객체를 설정해준다.
 *  1. UserDetails 구현
 *  2. user객체 내부에 선언 및 생성자 추가
 *  3. UserDetails 메서드 오버라이딩
 */
public class PrincipalDetail implements UserDetails {
    private User user; // 콤포지션 ( 객체를 내부에 품는 것)

    public PrincipalDetail(User user) {
        this.user = user;
    }

    /**
     * 계정이 가지고 있는 권한 목록 반환
     * @return
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {

        Collection<GrantedAuthority> collection = new ArrayList<>();

//        collection.add(new GrantedAuthority() {
//            @Override
//            public String getAuthority() {
//                return "ROLE_" + user.getRole();    // 앞에 ROLE_ 를 붙여줘야 인식할 수 있다.
//            }
//        });
        /*
         java1.8 문법 : add 안에 올 수 있는 타입은 GrantedAuthority 밖에 없고 메서드도 getAutority 하나밖에 없어서 ()->{}로 변경 가능
         */

        // role이 여러개만 반복만 필요
        collection.add(()->{
            return "ROLE_" + user.getRole(); // 앞에 ROLE_ 를 붙여줘야 인식할 수 있다.
        });

        return collection;
    }

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

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

    /**
     * 계정이 만료된게 아닌지 ( true : 만료 안 됨)
     * @return
     */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /**
     * 계정이 잠긴게 아닌지 ( true : 안 잠김)
     * @return
     */
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /**
     * 패스워드가 만료된게 아닌지 ( true : 만료 안 됨)
     * @return
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /**
     * 계정이 활성화 됐는지 (true : 활성화)
     * @return
     */
    @Override
    public boolean isEnabled() {
        return true;
    }
}
package com.jig.blog.config.auth;

import com.jig.blog.model.User;
import com.jig.blog.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
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;

/**
 * 스프링시큐리티 로그인 처리 설정
 *  - UserDetailsService 구현
 */
@Service
public class PrincipalDetailService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    /**
     * username 확인
     *
     * 스프링이 로그인 요청을 가로챌 때, username과 password 변수2개를 가로채는데 password는 알아서 비교해준다.
     * username이 DB에 있는지만 확인해주면 된다.
     *
     * @param username
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User principal = userRepository.findByUsername(username)
                .orElseThrow(()->{
                    return new UsernameNotFoundException("해당 사용자를 찾을 수 없습니다. : " + username);
                });

        return new PrincipalDetail(principal); // 반환한 객체를 스프링 시큐리티 세션에 유저 정보( UserDatails 타입)를 저장해준다. // 직접 구현한 user 객체를 넣어서 반환하지 않으면 기본 user가 사용된다( id: user, password: 스프링 run하면 콘솔창에서 확인가능한 password)
    }
}
package com.jig.blog.config;

import com.jig.blog.config.auth.PrincipalDetailService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

/**
 * 스프링 시큐리티 기본 설정
 */
@Configuration // 싱글톤 스프링 빈 등록
@EnableWebSecurity // 시큐리티 필터 등록
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true) // securedEnabled = true -> @Secured 어노테이션 활성화 // prePostEnabled = true -> @PreAuthorize, @PostAuthorize 어노테이션 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 패스워드를 암호화 해주는 빈
     * @return
     */
    @Bean
    public BCryptPasswordEncoder encoderPWD() {
        return new BCryptPasswordEncoder();
    }

    @Autowired
    private PrincipalDetailService principalDetailService;

    /**
     * 해싱(Hashing) :  단방향 암호화 기법
     * 암호화(Encryption) : 양방향 암호화 기법
     *
     * 비밀번호를 저장할 때는 행여 탈취될 가능성을 있기 때문에 평문을 암호화하는 것은 가능하지만 다시 평문으로 복호화하는 것은 불가능한 단방향 암호화 방식을 사용한다.
     * 같은 데이터를 같은 해시 알고리즘을 통해 암호활 경우 항상 같은 결과가 나오기 때문에 복호화가 불가능해도 사용자 인증은 가능하다.
     *
     * 로그인할 때 시큐리티가 가로채서 패스워드를 해싱하는데,
     * 회원가입할 떄 사용한 해시 알고리즘과 같은 알고리즘을 사용하여 해싱 한다.
     * 따라서 DB에 있는 해쉬데이터랑 비교하여 로그인 할 수 있다.
     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {

        // 로그인 할 때 패스워드를 encoderPWD()로 인코드 하고 principalDetailService로 username, password 처리 (DB와 비교)를 한다.
        auth.userDetailsService(principalDetailService).passwordEncoder(encoderPWD());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()// csrf 토큰 비활성화 ( 테스트 시에 설정 필요 )
            .authorizeRequests()
                .antMatchers("/", "/auth/**", "/js/**", "/css/**", "/image/**")  // 이 경로로 들어오면    ( "/auth/loginForm", "/auth/joinForm" )
                .permitAll()                         // 누구나 허용
                .anyRequest()    // 위 경로가 아닌 요청은
                .authenticated() // 인증이 필요하다
            .and()               // 그리고
                .formLogin()     // 위 경로가 아닌 요청은 로그인 페이지로 이동시키고
                .loginPage("/auth/loginForm") // 로그인 페이지는 이 경로로 하겠다.
                .loginProcessingUrl("/auth/loginProc") // 스프링 시큐리티가 이 요청을 가로채서 대신 로그인 해준다. UserDetails 상속 객체 필요(직접 controller 구현 필요없음)
                .defaultSuccessUrl("/"); // 정상 로그인 후 이동되는 경로
    }
}

 

tip)

권한에 대한 접근 처리가 필요한 경우

 - 광범위 하게 적용  : config에서 .authorizeRequest().antMatchers()... 을 사용

 - 부분적으로 특정 메서드에 적용 : @EnabledGlobalMethodSecurity로 @Secured, @PreAuthorize 를 활성화 해서 사용

 

 

참고

해싱 : [Spring Boot] Spring Security 적용하기 - 암호화 (tistory.com)


에러 분석

개발자 도구

 loginForm:1 
       Refused to execute script from 'http://localhost:8000/auth/loginForm' because its MIME type ('text/html') is not executable, and strict MI   ME type checking is enabled

원인

 - http://localhost:8000/auth/loginForm 요청으로 이동한 jsp 파일에서 /js/user.js 호출하고 있는데 허용되지 않은 경로이기 때문.

해결

-  스프링 시큐리티 config에서 허용 경로에 "/js/**" 추가

 .antMatchers("/auth/**", "/js/**", "/css/**", "/image/**")

 


ajax fail

 - error메세지에는 html이 담김

{"readyState":4,"responseText":"\r\n\r\n\r\n\r\n\r\n<!DOCTYPE html>\r\n<html lang=\"en\">\r\n<head>\r\n\t<title>Bootstrap Example</title>\r\n\t<meta charset=\"utf-8\">\r\n\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\r\n\t<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap.min.css\">\r\n\t<script src=\"https://cdn.jsdelivr.net/npm/jquery@3.6.1/dist/jquery.min.js\"></script>  \r\n\t<script src=\"https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js\"></script>\r\n\t<script src=\"https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/js/bootstrap.bundle.min.js\"></script>\r\n</head>\r\n<body>\r\n\r\n<nav class=\"navbar navbar-expand-md bg-dark navbar-dark\">\r\n\t<a class=\"navbar-brand\" href=\"/\">🏠</a>\r\n\t<button class=\"navbar-toggler\" type=\"button\" data-toggle=\"collapse\" data-target=\"#collapsibleNavbar\">\r\n\t\t<span class=\"navbar-toggler-icon\"></span>\r\n\t</button>\r\n\t<div class=\"collapse navbar-collapse\" id=\"collapsibleNavbar\">\r\n\t\t\r\n\t\t\t\r\n\t\t\t\t<ul class=\"navbar-nav\">\r\n\t\t\t\t\t<li class=\"nav-item\">\r\n\t\t\t\t\t\t<a class=\"nav-link\" href=\"/auth/loginForm\">로그인</a>\r\n\t\t\t\t\t</li>\r\n\t\t\t\t\t<li class=\"nav-item\">\r\n\t\t\t\t\t\t<a class=\"nav-link\" href=\"/auth/joinForm\">회원가입</a>\r\n\t\t\t\t\t</li>\r\n\t\t\t\t</ul>\r\n\t\t\t\r\n\t\t\t\r\n\t\t\r\n\r\n\r\n\r\n\t</div>\r\n</nav>\r\n<br/>\r\n\r\n<div class=\"container\">\r\n    <form action=\"#\" method=\"post\">\r\n        <div class=\"form-group\">\r\n            <label for=\"username\">Username:</label>\r\n            <input type=\"text\" ...

원인

 - 스프링시큐리티가 default로 csrf 를 활성화 하고 있어서 ajax에 csrf 토큰을 포함시키지 않으면 발생

해결

 - 방법1. 스프링시큐리티 config에서 csrf 비활성화

.csrf().disable()

 - 방법2. ajax 요청에 토큰 포함

참고

- Spring Boot CSRF AJAX 전송 방법 (tistory.com)

 - 스프링 시큐리티 CSRF 필터 - DSlog (pds0309.github.io)

 

 

 

 

테스트 코드 작성

Study_Spring-Security/AccountControllerTest.java at master · jaenyeong/Study_Spring-Security · GitHub

Spring Security Test - SMJ Blog (smjeon.dev)

JUnit 테스트코드와 TDD 적용하기 | Yoon’s Github Blog (jiyongyoon.github.io)

TDD와 BDD에서 사용되는 given/when/then 행동과 실습 (tistory.com)