개발

spring security를 살짝 적용해보자!

yjs3819 2021. 7. 30. 18:58
728x90

프로젝트를 진행하는데, 인증과 인가필요해서 찾아보다 spring security를 적용하면 접근하는 사용자가 누구인지에 대한 인증과 어떠한 자원에대한 접근이 가능한지에 대한 인가를 쉽게 적용할수 있다는 사실을 깨닫고 스프링 부트에서 spring security를 적용해려 했다. 그런데 너무 어려웠다. 설정도 복잡하고, spring security만의 기능을 이해하는데 힘들었다. 그래서 내가 스프링 시큐리티를 적용할 때 기억이 나지않을 경우 복기할 용도와, spring security를 처음 적용하는 사람에게 도움이 될까하고 포스팅을 하게되었다.

매우 간단하게 프로젝트를 진행할것이다! 초점은 스프링 시큐리티의 사용법이다. (본인도 스프링 시큐리티의 제대로된 매커니즘을 알지못한다.)

가자!

구현할 기능

어플리케이션을 사용하는 사용자에게 두개의 권한 ADMIN, USER를 줄 것이다.
사용자는 자신의 권한에 맞게 어떠한 리소스에 접근할수 있을 것이다.
ADMIN권한을 가진 사용자는 /admin 로 시작하는 모든 리소스에 접근할 수있고
USER권한을 가진 사용자는 /user/info 리소스에 접근할수 있다.

그외의 나머지 자원에 대한 접근은 모든 사용자가 접근할수있다.(권한이 있던 없던 모두 접근가능)

dependencies

sprint initializer 를 이용해서 쉽게 의존성 모듈을 의존해보자.


간단하게 이 여섯가지 모듈을 의존해보자!
(h2 database를 사용할것이고, spring security를 사용할 것이기에 spring security는 꼭필요!)

dir 구조

가보자!!!@!@!@!@

spring seucurity 설정

스프링 시큐리티를 사용하려면 WebSecurityConfigurerAdapter 클래스를 상속받아서 적절한 설정 메서드를 오버라이딩 해야한다. 이때 @EnableWebSecurity애너테이션을 붙여줌으로써 스프링 시큐리티 설정임을 알려준다.

package ex3.ex3.config;

import ex3.ex3.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.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

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

    //spring security를 사용하는데 있어서 로그인은 UserDetailService를 통해서 정보를 가져온다.
    //MemberService는 UserDetailService를 구현할 것이다.
    private final MemberService memberService;

    //서비스에서 비밀번호를 암호화하는 이 메서드를 사용할것이기에 빈으로 등록해놓음. (암호는 암호화돼서 DB에 저장되도록 할것이기 때문에)
    @Bean
    public BCryptPasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    // http 권한 관련 설정 메서드
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                    .authorizeRequests()
                    .antMatchers("/admin/**").hasRole("ADMIN") // /admin 경로 자원들 ADMIN권한만 접근가능
                    .antMatchers("/user/info").hasRole("USER") // /user/info 자원에는 USER권한만 접근가능
                    .antMatchers("/**").permitAll() //나머진 누구든 접근가능
                .and()
                    .formLogin()
                    .loginPage("/user/login") // custom login - 설정하지않으면 default 로 로그인 페이지가 /login임.
                    .defaultSuccessUrl("/user/login/result") //로그인 성공하면 이 url로 redirect
                .and()
                    .logout()
                    .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout")) // custom logout - 설정하지 않으면 default로 로그아웃 페이지가 /logout임.
                    .logoutSuccessUrl("/user/logout/result") // 로그아웃 성공시 redirect
                    .invalidateHttpSession(true) // 로그아웃 성공시 session 모두제거
                .and()
                    .exceptionHandling().accessDeniedPage("/user/denied"); // 권한 거부된 페이지로 redirect 되게 하기위해서.
    }

    // 로그인 처리를 위한 메서드라 생각하자!(memberService는 UserDeatilService를 구현했기에 MemberService에는 loadUserByUsername()이라는 메서드가 구현되어있을것이다. 이 메서드가 꼭 구현되어야한다.)
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(memberService).passwordEncoder(passwordEncoder());
    }
}
  • 매개변수가 HttpSecurity인 configuer메서드는 http관련 설정 메서드이다. 이를 통해서 권한에 따른 리소스에대한 접근을 제한할수 있다.(자세한건 주석)
  • 매개변수가 AuthenticationManagerBuilder인 configuer메서드는 스프링 시큐리티에서 제공하는 login기능을 사용할수 있는 설정 메서드이다. 스프링 시큐리티를 이용하면 따로 login에대한 post요청으로 로그인을 처리할필요없이 스프링 시큐리티에서 로그인 기능을 제공한다. 이를 위해서 UserDetailService의 loadUserByUsername메서드를 구현하는 것이 필요하다. 나는 MemberService에서 해당 메서드를 구현했고 configuer 설정메서드에서 memberSerivce를 콕 집어넣어주었다. 그리고 비밀번호는 바로 DB에 저장되지않고 암호화하여 저장되게 하였기에, passwordEncoder메서드로 암호화 한 비밀번호를 확인하는 로그인 작업이다. 이 메서드로 로그인 기능이 구현된다 보면된다!

엔티티 클래스와 권한을 저장할 ENUM

package ex3.ex3.domain;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;

@Entity
@Getter @Setter
public class Member {

    @Id @GeneratedValue
    private Long id;

    @Column(unique = true)
    private String username;

    private String password;

    @Enumerated(EnumType.STRING)
    private Role role;
}

별건 없다. 이 엔티티는 role필드를 가지고있다.

package ex3.ex3.domain;

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public enum Role {
    ADMIN("ROLE_ADMIN"),
    USER("ROLE_USER");

    private String value;
}

리파지토리를 만들어보쟈

package ex3.ex3.repository;

import ex3.ex3.domain.Member;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import javax.persistence.EntityManager;
import java.util.List;

@Repository
@RequiredArgsConstructor
public class MemberRepository {
    private final EntityManager em;

    public Long save(Member member){
        em.persist(member);
        return member.getId();
    }

    public List<Member> findByUsername(String username){
        List<Member> members = em.createQuery("select m from Member m where m.username = :username", Member.class)
                .setParameter("username", username)
                .getResultList();
        return members;
    }
}
  • spring data jpa를 의존하긴 했지만 사용경험이 부족하여 jpaRepository를 이용하지않고 직접 영속화하고 jpql 쿼리를 만들었고, 이를 EntityManager로 영속화하였다. JpaRepository를 이용하면 더 쉽게 구현가능하다!

서비스를 만들어보쟈

package ex3.ex3.service;

import ex3.ex3.domain.Member;
import ex3.ex3.domain.Role;
import ex3.ex3.dto.MemberDto;
import ex3.ex3.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
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;

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

    @Transactional
    public Long joinUser(MemberDto memberDto){
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        memberDto.setPassword(passwordEncoder.encode(memberDto.getPassword()));
        Member member = memberDto.toEntity();
        Long memberId = memberRepository.save(member);
        return memberId;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        List<Member> members = memberRepository.findByUsername(username);
        if(members.size() == 0){
            throw new IllegalStateException("회원 없음");
        }
        Member member = members.get(0);

        List<GrantedAuthority> authorities = new ArrayList<>();
        if(member.getRole() == Role.USER){
            //USER
            authorities.add(new SimpleGrantedAuthority(Role.USER.getValue()));
        }else{
            //ADMIN
            authorities.add(new SimpleGrantedAuthority(Role.ADMIN.getValue()));
        }

        return new User(member.getUsername(), member.getPassword(), authorities);
    }
}

joinUser를 회원가입 로직이고 loadUserByUsername메서드가 중요한데, 스프링 시큐리티는 로그인 기능을 제공하기에 꼭 필요한 메서드이다. username으로 찾은 멤버의 role을 확인하여 권한을 로그인 할 때 부여하는 로직이다. UserDetails라던가 리턴하는 타입의 매개변수라던가 디테일한 부분은 모르지만, 리턴하는 매개변수는 순서대로 로그인한 username, password 그리고 해당 유저의 권한이 들어간다.

DTO

뷰의 폼으로부터 입력되는 값을 정제하는 MemberDto이다.
간단하다

package ex3.ex3.dto;

import ex3.ex3.domain.Member;
import ex3.ex3.domain.Role;
import lombok.Getter;
import lombok.Setter;

@Getter @Setter
public class MemberDto {
    private Long id;
    private String username;
    private String password;
    private Role role;

    public Member toEntity(){
        Member member = new Member();
        member.setId(this.id);
        member.setUsername(this.username);
        member.setPassword(this.password);
        member.setRole(this.role);

        return member;
    }
}

컨트롤러를 만들어보쟈

package ex3.ex3.controller;

import ex3.ex3.domain.Role;
import ex3.ex3.dto.MemberDto;
import ex3.ex3.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
@RequiredArgsConstructor
public class MemberController {
    private final MemberService memberService;

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

    @GetMapping("/user/signup")
    public String dispSignup(){
        return "signup";
    }

    @PostMapping("/user/signup")
    public String signup(
            @RequestParam String username,
            @RequestParam String password,
            @RequestParam String role
    ){
        MemberDto memberDto = new MemberDto();
        memberDto.setUsername(username);
        memberDto.setPassword(password);
        for (Role roleV : Role.values()) {
            if(roleV.getValue().equals(role)) memberDto.setRole(roleV);
        }
        memberService.joinUser(memberDto);

        return "redirect:/user/login";
    }

    // 로그인 페이지
    @GetMapping("/user/login")
    public String dispLogin() {
        return "login";
    }

    // 로그인 결과 페이지
    @GetMapping("/user/login/result")
    public String dispLoginResult() {
        return "loginSuccess";
    }

    // 로그아웃 결과 페이지
    @GetMapping("/user/logout/result")
    public String dispLogout() {
        return "logout";
    }

    // 접근 거부 페이지
    @GetMapping("/user/denied")
    public String dispDenied() {
        return "denied";
    }

    // 내 정보 페이지
    @GetMapping("/user/info")
    public String dispMyInfo() {
        return "info";
    }

    // 어드민 페이지
    @GetMapping("/admin")
    public String dispAdmin() {
        return "admin";
    }
}

뷰랑 하나하나 연결해가며 확인하면 어렵지않다.

이제 자바를 통한 코드구현은 끝이다. 이제 처음에 설정한 타임리프로 뷰를 설정할 건데 뷰에서 알아야할것들이 몇개 있기에 하나씩 적어보겠다.

뷰를 만들어보쟈

home

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

sec:authorize를 통해서 권한에 따라 다르게 렌더링 되도록 하였다.
isAnonymous()는 인증되지않은 사용자, isAuthenticated()는 인증된 사용자, 그리고 hasRole로 각 권한의 사용자면 해당 태그들이 렌더링 되도록 하였다.

signup

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"><head>

    <meta charset="UTF-8">
    <title>회원가입 페이지</title>
</head>
<body>
<h1>회원 가입</h1>
<hr>

<form th:action="@{/user/signup}" method="post">
    <input type="text" name="username" placeholder="유저이름을 입력해주세요">
    <input type="password" name="password" placeholder="비밀번호">
    <div>
        <label>
            user
            <input type="radio" name="role" value="ROLE_USER" checked>
        </label>
        <label>
            admin
            <input type="radio" name="role" value="ROLE_ADMIN">
        </label>
    </div>
    <button type="submit">가입하기</button>
</form>
</body>
</html>

회원가입 뷰이다.
스프링 시큐리티를 이용하면 form전송시 csrf토큰을 함께 보내야한다. 그래서 이 뷰의 소스보기를하면 input hidden으로 csrf토큰이 함께 보내지는것을 확인할수 있다.

login

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.w3.org/1999/xhtml"><head>
    <meta charset="UTF-8">
    <title>로그인 페이지</title>
</head>
<body>
<h1>로그인</h1>
<hr>

<form action="/user/login" method="post">
    <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />

    <input type="text" name="username" placeholder="이메일 입력해주세요">
    <input type="password" name="password" placeholder="비밀번호">
    <button type="submit">로그인</button>
</form>
</body>
</html>

로그인 뷰이다.
회원가입과 다르게 직접 이렇게 csrf토크을 설정해줘도된다. 그러나 th:action으로 자동으로 설정되도록 하는것이 편하겠다.

loginSuccess

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.w3.org/1999/xhtml"><head>
    <meta charset="UTF-8">
    <title>로그인 성공</title>
</head>
<body>
<h1>로그인 성공!!</h1>
<hr>
<p>
    <span sec:authentication="name"></span>님 환영합니다~
</p>
<a th:href="@{'/'}">메인으로 이동</a>
</body>
</html>

로그인 성공시 렌더링될 뷰이다.
sec:authentication으로 인증된 사용자의 이름이 렌더링되도록 할수 있다.

logout

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.w3.org/1999/xhtml"><head>

    <meta charset="UTF-8">
    <title>로그아웃</title>
</head>
<body>
<h1>로그아웃 처리되었습니다.</h1>
<hr>
<a th:href="@{'/'}">메인으로 이동</a>
</body>
</html> 

로그아웃 뷰이다.

admin, info, 그리고 권한이 없을 경우 렌더링할 denied 뷰

admin

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>어드민</title>
</head>
<body>
<h1>어드민 페이지입니다.</h1>
<hr>
</body>
</html>

info

<!DOCTYPE html>
<html><head>
    <meta charset="UTF-8">
    <title>내정보</title>
</head>
<body>
<h1>내정보 확인 페이지입니다.</h1>
<hr>
</body>
</html>

denied

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>접근 거부</title>
</head>
<body>
<h1>접근 불가 페이지입니다.</h1>
<hr>
</body>
</html>

동작 확인

동작을 확인해보쟈아!

먼저 최초에 /로 요청을하면 인증된 사용자가 아니므로 인증된 사용자가 아닐경우 렌더링되는 태그만 존재한다.

회원가입을 해보자


권한은 USER로 한뒤 회원가입을 하고 db를 확인해보쟈아아


잘저장이 된걸 확인할수 있다.

이제 로그인을 해보자

 


잘 성공이 되었고 메인페이지로 가보자!


인증되었고 USER권한을 가지고있으므로 내정보로가기 태그가 생겼다.
가보면


USER권한으로 로그인했으므로 해당 리소스에 접근할수있다.

admin으로 가보면?


권한이 없음으로 거부 페이지가 렌더링된걸 확인할수 있다.

결론

보다 복잡한 매커니즘이 내부적으로 동작하겠지만 사용하는 법만 알면 어느정도 사용할수 있다. 프로젝트에 제대로 적용을 해본뒤, 스프링 시큐리티의 매커니즘에 대해서 공부하는 시간을 가져보자!

(깃허브 레포 링크)

https://github.com/YeomJaeSeon/spring-security-examples/tree/main/ex3

728x90