[初心-Spring Boot] 게시판 제작 - 3. Spring Security 적용

0

초심-spring boot

목록 보기
15/16

1. Spring Security란?


스프링 시큐리티는 인증과 권한 부여를 모두 제공하는 데 중점을 두고 사용자 정의 가능한 인증 및 액세스 제어 프레임워크이다.
Spring 기반 애플리케이션을 보호하기 위한 사실상의 표준이다.

Spring Security 특징

  • 인증 및 권한 부여 모두에 대한 포괄적이고 확장 가능한 지원
  • session fixation, clickjacking, cross site request forgery 등의 공격으로부터 보호
  • 서블릿 API 통합
  • Spring Web MVC와의 선택적 통합
예제 파일 경로

2. 의존성 추가


file : build.gradle
plugins {
	id 'org.springframework.boot' version '2.5.3'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	id 'java'
}

group = 'com.rptp'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
  //==================add==================
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
	implementation 'org.springframework.security:spring-security-test'
 // ==================add==================

	annotationProcessor "org.projectlombok:lombok"
	compileOnly "org.projectlombok:lombok"


	testImplementation 'org.springframework.boot:spring-boot-starter-test'

	testCompileOnly 'org.projectlombok:lombok:1.18.20'
	testAnnotationProcessor 'org.projectlombok:lombok:1.18.20'

	runtimeOnly 'mysql:mysql-connector-java'
}

test {
	useJUnitPlatform()
}

3. Security Config 생성


package com.rptp.rptpSpringBoot.common.security;

import com.rptp.rptpSpringBoot.core.member.service.MemberService;
import lombok.RequiredArgsConstructor;
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.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
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;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

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

    private final MemberService memberService;

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

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/css/**", "/js/**", "/img/**", "/lib/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/admin/**").hasRole("ADMIN")
                .antMatchers("/**").permitAll()
                .and()
                .formLogin()
                .loginPage("/login")
                .defaultSuccessUrl("/login-success")
                .permitAll()
                .and()
                .logout()
                .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
                .logoutSuccessUrl("/logout-success")
                .invalidateHttpSession(true)
                .and()
                .exceptionHandling().accessDeniedPage("/denied");
    }

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

코드 세부 설명

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

비밀번호를 해시화하는 클래스를 주입받는다.
BCryptPasswordEncoder는 BCrypt 해싱 함수(BCrypt hashing function)를 사용해서 비밀번호를 인코딩해주는 메서드와 사용자의 의해 제출된 비밀번호와 저장소에 저장되어 있는 비밀번호의 일치 여부를 확인해주는 메서드를 제공한다.
생성자의 인자 값(verstion, strength, SecureRandom instance)을 통해서 해시의 강도를 조절할 수 있다.

 @Override
 public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/css/**", "/js/**", "/img/**", "/lib/**");
 }

WebSecurity는 FilterChainProxy를 생성하는 필터이다.
antMatchers에 명시된 주소에 위치한 파일들은 인증을 무시한다.

@Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                 // (1)
                .antMatchers("/admin/**").hasRole("ADMIN")
                .antMatchers("/**").permitAll()
                
                
                 // (2)
                .and()
                .formLogin()
                .loginPage("/login")
                .defaultSuccessUrl("/login-success")
                .permitAll()
                
                // (3)     
                .and()
                .logout()
                .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
                .logoutSuccessUrl("/logout-success")
                .invalidateHttpSession(true)
                
                 // (4)     
                .and()
                .exceptionHandling().accessDeniedPage("/denied");
    }

HttpSecurity를 통해 HTTP 요청에 대한 웹 기반 보안을 구성할 수 있다.
(1). antMatchers() 메서드로 특정 경로를 지정하며, permitAll(), hasRole() 메서드로 역할(Role)에 따른 접근 설정을 잡아준다.

(2). formLogin()를 통해 form 기반으로 인증을 하도록 한다. 로그인 정보는 기본적으로 HttpSession을 이용한다.

(3). 로그아웃을 지원하는 메서드이며, WebSecurityConfigurerAdapter를 사용할 때 자동으로 적용된다.
기본적으로 "/logout"에 접근하면 HTTP 세션을 제거한다.

(4). 403 예외처리에 관한 핸들링을 처리한다.

4. Member entity role 추가

4-1. Role enum 생성

file : Role.java
package com.rptp.rptpSpringBoot.core.member.domain;

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public enum Role {
    ADMIN("ROLE_ADMIN"),
    MEMBER("ROLE_MEMBER"),
    GUEST("ROLE_GUEST");

    private String value;
}

4-2. member entity 수정

file : Member.java
package com.rptp.rptpSpringBoot.core.member.domain;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import javax.persistence.*;

@Entity
@Getter
@Setter
@NoArgsConstructor
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long memberId;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String password;

    private String profilePhoto;

    @Column(nullable = false)
    private String nickName;
    
//==================add==================
    @Column(nullable = false)
    @Enumerated(EnumType.STRING)
    private Role role = Role.GUEST;
//==================add==================

    @Builder
    public Member(String name, String password, String profilePhoto, String nickName) {
        this.name = name;
        this.password = password;
        this.profilePhoto = profilePhoto;
        this.nickName = nickName;
    }
}

5. MemberRepository findByName 메서드 추가

file : MemberRepository.java
package com.rptp.rptpSpringBoot.core.member.domain;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {
//==================add==================
    Optional<Member> findByName(String name);
//==================add==================
}

6. MemberService 에 UserDetailService 인터페이스 구현

file : MemberService.java
package com.rptp.rptpSpringBoot.core.member.service;

import com.rptp.rptpSpringBoot.core.member.domain.Member;
import com.rptp.rptpSpringBoot.core.member.domain.MemberRepository;
import com.rptp.rptpSpringBoot.core.member.domain.Role;
import com.rptp.rptpSpringBoot.core.member.dto.SignUpRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;


import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

@Service
@RequiredArgsConstructor
//==================edit==================
public class MemberService implements UserDetailsService {
//==================edit==================
    private final MemberRepository memberRepository;

    private BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    @Transactional
    public Long signUp(SignUpRequest req) {
        return  memberRepository.save(buildMember(req)).getMemberId();
    }

    private Member buildMember(SignUpRequest req) {
        return Member.builder()
                .name(req.getName())
        //==================edit==================
  		.password(passwordEncoder.encode(req.getPassword()))
        //==================edit==================
                .profilePhoto(req.getProfilePhoto())
                .nickName(req.getNickName())
                .build();
    }
//==================add==================
    @Override
    public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException {
        Member member = memberRepository.findByName(name)
                .orElseThrow(() -> new UsernameNotFoundException(name + "은 존재하지 않습니다"));

        List<GrantedAuthority> authorities = new ArrayList<>();

        if (member.getRole() == Role.ADMIN) {
            authorities.add(new SimpleGrantedAuthority(Role.ADMIN.getValue()));
        }

        authorities.add(new SimpleGrantedAuthority(Role.MEMBER.getValue()));

        return new User(member.getName(), member.getPassword(), authorities);
    }
    //==================add==================

}

7. view 추가 & 수정 및 핸들링 추가

admin.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
            xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>어드민</title>
</head>
<body>
<h1>어드민 페이지입니다.</h1>
<hr>
</body>
</html>
demied.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>접근 거부</title>
</head>
<body>
<h1>접근 불가 페이지입니다.</h1>
<hr>
</body>
</html>
index.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="UTF-8">
    <title>RPTP</title>
</head>
<body>
<h1>메인 페이지</h1>
<hr>
<a sec:authorize="isAnonymous()" th:href="@{/login}">로그인</a>
<a sec:authorize="isAuthenticated()" th:href="@{/logout}">로그아웃</a>
<a sec:authorize="isAnonymous()" th:href="@{/sign-up}">회원가입</a>
<a sec:authorize="hasRole('ROLE_MEMBER')" th:href="@{/user}">내정보</a>
<a sec:authorize="hasRole('ROLE_ADMIN')" th:href="@{/admin}">어드민</a>
</body>
</html>

sec:authorize를 통해 로그인 유저의 상태에 따라 해당 태그의 표시 여부를 다르게 한다.

login.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org"
      xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
  <title>Spring Security Example </title>
</head>
<body>
<div th:if="${param.error}">
  유효하지 않은 아이디 또는 비밀번호입니다
</div>
<div th:if="${param.logout}">
  로그아웃
</div>
<form th:action="@{/login}" method="post">
  
  <!--input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" /-->
  <div><label> User Name : <input type="text" name="username"/> </label></div>
  <div><label> Password: <input type="password" name="password"/> </label></div>
  <div><input type="submit" value="로그인"/></div>
</form>
</body>
</html>

Spring Security가 적용되면 POST 방식으로 보내는 모든 데이터는 csrf 토큰 값이 필요하다.
그래서 원래는 form에 csrf 토큰을 같이 보내줘야 하나, 타임리프의 th:action을 사용하면 자동으로 csrf를 같이 보내준다

login-success.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>RPTP</title>
</head>
<body>
로그인 완료!
<a th:href="@{/}">메인으로</a>
</body>
</html>
logout.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
  <meta charset="UTF-8">
  <title>로그아웃</title>
</head>
<body>
<h1>로그아웃 처리되었습니다.</h1>
<hr>
<a th:href="@{'/'}">메인으로 이동</a>
</body>
</html>
logout-success.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>RPTP</title>
</head>
<body>
로그아웃 완료!
<a th:href="@{/}">메인으로</a>
</body>
</html>
sign-up.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>RPTP</title>
</head>
<body>
<form th:action="@{/api/member}" method="post">
    <label>아이디<input type="text" name="name"></label>
    <label>password<input type="password" name="password"></label>
    <label>프로필사진<input type="text" name="profilePhoto"></label>
    <label>별명<input type="text" name="nickName"></label>
    <input type="submit" value="가입하기">
</form>

</body>
</html>
sign-up-success.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>RPTP</title>
</head>
<body>
회원가입 완료!
<a th:href="@{/}">메인으로</a>
</body>
</html>
MainController.java
package com.rptp.rptpSpringBoot.api;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/")
@RequiredArgsConstructor
public class MainController {

    @GetMapping("")
    public String index(){
        return "/index";
    }

    @GetMapping("sign-up")
    public String signUp() {
        return "/sign-up";
    }

    @GetMapping("sign-up-success")
    public String signUpSuccess() {
        return "/sign-up-success";
    }

    @GetMapping("login")
    public String login() {
        return "/login";
    }

    @GetMapping("login-success")
    public String loginSuccess() {
        return "/login-success";
    }

    @GetMapping("logout-success")
    public String logoutSuccess() {
        return "/logout-success";
    }

    @GetMapping("denied")
    public String denied() {
        return "/denied";
    }

    @GetMapping("/admin")
    public String admin() {
        return "/admin";
    }

}

실행 결과

처음 접속시 메인 페이지

회원가입

회원가입 완료

로그인

로그인 완료

일반 유저 메인 페이지

로그아웃 페이지

운영자 메인 페이지 - db에서 직접 ROLE을 ADMIN으로 변경

참조


https://mangkyu.tistory.com/76
https://victorydntmd.tistory.com/328
https://kimvampa.tistory.com/129

0개의 댓글