Spring 게시판 프로젝트 2편 (회원가입, 로그인) Created Time: July 13, 2022 7:08 PM Last Edited Time: September 2, 2022 1:01 PM Tags: Java, Spring, Computer
로그인 구현 Spring Security란? Spring Security는 스프링 기반 에플리케이션의 보안을 담당 해주는 스프링 하위 프레임워크이다. 이를 활용하면 개발자가 직접 보안 관련 로직을 짜는 수고를 덜 수가 있다.
용어정리(인증후 권한부여됨.)
접근 주체(Principal)
보호되어 있는 리소스에 접근 하고자 하는 대상
인증(Authentication) ‘유저’를 확인 하는 작업, 접근한 대상이 어떤 종류의 유저인지 확인하는 과정 클라이언트가 자신이 주장하는 사용자와 같은 사용자인지를 확인하는 과정
권한(Authorization) 어떤 리소스에 대한 접근 제한을 의미, 모든 리소스는 각각 권한이 걸려있으며, 인가 과정에서 최소한의 권한을 확인하는 것 권한부여, 클라이언트가 하고자 하는 작업이 해당 클라이언트에게 허가된 작업인지 확인
제공하는기능
애플리케이션의 모든 URL에 대해 인증을 요구함
로그인 양식을 생성해 줌
아이디 및 암호 를 가진 사용자의 양식 기반 인증 기능
로그아웃 기능
CSRF 공격 방지
Session Fixation 보호해줌
보안 헤더 통합
아래의 서블릿 API 메소드들과 통합
출처
session 인증 로그인
springSecurity 동작원리
토큰을 이용한 로그인 구현(JWT)
타임리프 사용자정보 추출 방법
build.gradle 1 2 implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
spring-boot-starter-security : 스프링 시큐리티를 사용하기 위해 추가
thymeleaf-extras-springsecurity5 : 뷰 단에서 현재 로그인된 사용자의 정보를 가져오기 위해 추가(Thymeleaf에서 Spring Security를 이용하기 위해)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 <h1 sec:authorize ="hasRole('ADMIN')" > Has admin Role</h1 > <h1 sec:authorize ="hasRole('USER')" > Has user Role</h1 > <div sec:authorize ="isAuthenticated()" > Only Authenticated user can see this Text </div > <b > Authenticated DTO:</b > <div sec:authentication ="principal" > </div > <b > Authenticated username:</b > <div sec:authentication ="name" > </div > <b > Authenticated user role:</b > <div sec:authentication ="principal.authorities" > </div >
Config 파일 작성
WebSecurityConfigurerAdapter를 상속받아 구현하면된다
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 @RequiredArgsConstructor @EnableWebSecurity @Configuration public class WebSecurityConfig extends WebSecurityConfigurerAdapter { private final UserService userService; @Override public void configure (WebSecurity web) { web.ignoring().antMatchers("/css/**" , "/js/**" , "/img/**" ); } @Override protected void configure (HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/login" , "/signup" , "/user" ).permitAll() .antMatchers("/" ).hasRole("USER" ) .antMatchers("/admin" ).hasRole("ADMIN" ) .anyRequest().authenticated() .and() .formLogin() .loginPage("/login" ) .defaultSuccessUrl("/" ) .and() .logout() .logoutSuccessUrl("/login" ) .invalidateHttpSession(true ) ; } @Override public void configure (AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userService) .passwordEncoder(new BCryptPasswordEncoder()); } }
Spring Security를 활성화한다는 의미의 어노테이션입니다.
WebSecurityConfigurerAdapter는 Spring Security의 설정파일로서의 역할을 하기 위해 상속해야 하는 클래스입니다.
후에 사용할 유저 정보를 가져올 클래스입니다. 아직 만들어지지 않았습니다.
WebSecurityConfigurerAdapter
를 상속받으면 오버라이드할 수 있습니다. 인증을 무시할 경로들을 설정해놓을 수 있습니다.
static 하위 폴더 (css, js, img)는 무조건 접근이 가능해야하기 때문에 인증을 무시해야합니다.
WebSecurityConfigurerAdapter
를 상속받으면 오버라이드할 수 있습니다.
접근에 대한 인증 설정이 가능합니다.
anyMatchers
를 통해 경로 설정과 권한 설정이 가능합니다.
permitAll()
: 누구나 접근이 가능
hasRole()
: 특정 권한이 있는 사람만 접근 가능
authenticated()
: 권한이 있으면 무조건 접근 가능
anyRequest
는 anyMatchers
에서 설정하지 않은 나머지 경로를 의미합니다.
로그인에 관한 설정을 의미합니다.
loginPage()
: 로그인 페이지 링크 설정
defaultSuccessUrl()
: 로그인 성공 후 리다이렉트할 주소
로그아웃에 관한 설정을 의미합니다.
logoutSccessUrl()
: 로그아웃 성공 후 리다이렉트할 주소
invalidateHttpSession()
: 로그아웃 이후 세션 전체 삭제 여부
로그인할 때 필요한 정보를 가져오는 곳입니다.
유저 정보를 가져오는 서비스를 userService
(아직 만들어지지 않음)으로 지정합니다.
패스워드 인코더는 아까 빈으로 등록해놓은 passwordEncoder()
를 사용합니다. (BCrypt)
이 외에도 더 많은 속성들이 있지만, 대표적으로 많이 쓰이는 설정들 위주로 정리했습니다. 이제 UserService를 만들기 위해 필요한 User 와 User 정보를 가져올 UserRepository 를 만들어보도록 하겠습니다.
User Entity작성
User 엔티티는 UserDetails를 상속받아서 구현합니다. UserDetails에서 필수로 구현해야 하는 메소드는 아래와 같습니다.(로그인시 반환되는것으로 권한, 이름(id),비번 등 모두 구현되어있어야함)
Untitled
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 @NoArgsConstructor (access = AccessLevel.PROTECTED)@Entity @Getter public class User implements UserDetails { @Id @Column (name = "id" ) @GeneratedValue (strategy= GenerationType.IDENTITY) private Long id; @Column (name = "email" , unique = true ) private String email; @Column (nullable = false ) private String nickname; @Column (name = "password" ) private String password; @Column (name = "role" ) private String role; @Builder public User (String email, String nickname, String password, String role) { this .email = email; this .nickname = nickname; this .password = password; this .role = role; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { Set<GrantedAuthority> roles = new HashSet<>(); for (String sprite_role : role.split("," )) { roles.add(new SimpleGrantedAuthority(sprite_role)); } return roles; } @Override public String getUsername () { return email; } @Override public String getPassword () { return password; } @Override public boolean isAccountNonExpired () { return true ; } @Override public boolean isAccountNonLocked () { return true ; } @Override public boolean isCredentialsNonExpired () { return true ; } @Override public boolean isEnabled () { return true ; } }
여기서 추가로 설명할 부분은 getAuthorities()
인데, 이 메소드는 사용자의 권한을 콜렉션 형태로 반환해야하고, 콜렉션의 자료형은 무조건적으로 GrantedAuthority
를 구현해야합니다. 저는 권한이 중복되면 안 되기 때문에 Set<GrantedAuthority>
을 사용했습니다.
GrantedAuthority(스프링시큐리티의 자료형)으로 반환해줘야함
ADMIN은 관리자의 권한(ADMIN)뿐만 아니라 일반 유저(USER)의 권한도 가지고 있기 때문에, ADMIN의 auth는 “ROLE_ADMIN,ROLE_USER”와 같은 형태로 전달이 될 것이고, 쉼표(,) 기준으로 잘라서 ROLE_ADMIN과 ROLE_USER를 roles에 추가해줍니다. 아까 루트 패스(“/“)에 권한을 USER에게만 주었지만, ADMIN은 두 개의 권한(USER, ADMIN)을 가지고 있기 때문에 접근이 가능합니다.
이제 User 엔티티를 모두 구현했으니, 정보를 가져오기 위해 필요한 Repository를 구현해보겠습니다.
User Repository작성 1 2 3 public interface UserRepository extends JpaRepository <UserInfo , Long > { Optional<UserInfo> findByEmail (String email) ; }
JpaRepository를 상속받아줍니다. 그리고 email를 통해 회원을 조회하기 위해 findByEmail()
를 만들어줍니다.
이제 Entity와 Repository까지 만들었으니, 아까 Config 파일에서 미리 주입해놓았던 UserService를 만들어보도록 하겠습니다.
User Service 작성 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 @RequiredArgsConstructor @Service public class UserService implements UserDetailsService { private final UserRepository userRepository; @Override public UserDetails loadUserByUsername (String email) throws UsernameNotFoundException { return userRepository.findByEmail(email) .orElseThrow(() -> new UsernameNotFoundException((email))); } public Long save (UserInfoDto infoDto) { BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); infoDto.setPassword(encoder.encode(infoDto.getPassword())); return userRepository.save(infoDto.toEntity()).getCode(); } }
아까 설정해놓은 서비스를 만들어주고, UserDetailService를 상속받습니다. 그 후에 필수 메소드인 loadUserByUsername()
를 구현하는데, 기본 반환 타입인 UserDetails를 UserInfo 로 바꿔줍니다. UserInfo는 UserDetails을 상속받았기 때문에 자동으로 다운 캐스팅이 됩니다.
그리고 방금 만든 UesrRepository의 findByEmail()
를 사용해서 null이면 UsernameNotFoundException
예외를 발생시키고, null이 아니면 UserInfo를 반환하게 처리해줍니다. 이렇게 하면 로그인 관련 기능 구현은 모두 완료되었습니다.
회원가입시 회원정보를 저장하는 메소드를 만들기 위해 UserService 를 수정합니다. 입력받은 패스워드를 BCrypt 로 암호화한 후에 회원을 저장해주는 메소드를 만들어줍니다.
User DTO 작성
폼으로 받을 회원정보를 매핑 시켜줄 객체만들기
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Getter @Setter public class UserInfoDto { private String email; private String password; private String auth; public UserInfo toEntity () { UserInfo userInfo = UserInfo.builder() .email(email) .auth(auth) .password(password) .build(); return userInfo; } }
Controller 작성 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @RequiredArgsConstructor @Controller public class UserController { private final UserService userService; @PostMapping ("/user" ) public String signup (UserInfoDto infoDto) { userService.save(infoDto); return "redirect:/login" ; } @GetMapping (value = "/logout" ) public String logoutPage (HttpServletRequest request, HttpServletResponse response) { new SecurityContextLogoutHandler().logout(request, response, SecurityContextHolder.getContext().getAuthentication()); return "redirect:/login" ; } }
회원가입시 UserInfoDto를 통해 값을 받고 save()호출
로그아웃시, 기본으로 제공해주는 SecurityContextLogoutHandler()
의 logout()
을 사용해서 로그아웃 처리를 해주었습니다.이제 로그아웃까지 구현했으니, 나머지를 구현해보도록 하겠습니다
요청에 해당하는 필요한 뷰 연결
1 2 3 4 5 6 7 8 9 10 11 @Configuration public class MvcConfig implements WebMvcConfigurer { public void addViewControllers (ViewControllerRegistry registry) { registry.addViewController("/" ).setViewName("main" ); registry.addViewController("/login" ).setViewName("login" ); registry.addViewController("/admin" ).setViewName("admin" ); registry.addViewController("/signup" ).setViewName("signup" ); } }
html, 뷰 파일 admin.html 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <!DOCTYPE html> <html xmlns ="http://www.w3.org/1999/xhtml" xmlns:sec ="https://www.thymeleaf.org/thymeleaf-extras-springsecurity3" > <head > <meta charset ="UTF-8" > <title > admin</title > </head > <body > <h2 > 관리자 전용 페이지</h2 > ID : <span sec:authentication ="name" > </span > <br > 소유 권한 : <span sec:authentication ="authorities" > </span > <br > <form id ="logout" action ="/logout" method ="POST" > <input type ="hidden" th:name ="${_csrf.parameterName}" th:value ="${_csrf.token}" /> <input type ="submit" value ="로그아웃" /> </form > </body > </html >
login.html 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 <!DOCTYPE html> <html lang ="en" xmlns:th ="http://www.w3.org/1999/xhtml" > <html xmlns ="http://www.w3.org/1999/xhtml" xmlns:sec ="https://www.thymeleaf.org/thymeleaf-extras-springsecurity3" xmlns:th ="http://www.w3.org/1999/xhtml" > <head > <meta charset ="UTF-8" > <title > login</title > </head > <body > <h1 > Login</h1 > <hr > <form action ="/login" method ="POST" > <input type ="hidden" th:name ="${_csrf.parameterName}" th:value ="${_csrf.token}" /> email : <input type ="text" name ="username" > <br > password : <input type ="password" name ="password" > <br > <button type ="submit" > Login</button > </form > <br > <a href ="/signup" > Go to join! →</a > </body > </html >
로그인할 때에는 csrf 를 보내줘야하기 때문에 hideen 타입으로 함께 보냅니다.
main.html 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 <!DOCTYPE HTML> <html xmlns:th ="http://www.thymeleaf.org" > <html xmlns ="http://www.w3.org/1999/xhtml" xmlns:sec ="https://www.thymeleaf.org/thymeleaf-extras-springsecurity3" xmlns:th ="http://www.w3.org/1999/xhtml" > <head > <title > main</title > </head > <head > <meta charset ="utf-8" > <link th:href ="@{/css/bootstrap.min.css}" href ="../../static/css/bootstrap.min.css" rel ="stylesheet" > <style > .container { max-width: 560px; } </style > </head > <body > <h2 > 회원 전용 페이지</h2 > ID : <span sec:authentication ="name" > </span > <br > 소유 권한 : <span sec:authentication ="authorities" > </span > <br > <form id ="logout" action ="/logout" method ="POST" > <input type ="hidden" th:name ="${_csrf.parameterName}" th:value ="${_csrf.token}" /> <input type ="submit" value ="로그아웃" /> </form > </body > </html >
signup.html 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 <!DOCTYPE html> <html lang ="en" xmlns:th ="http://www.w3.org/1999/xhtml" > <head > <meta charset ="UTF-8" > <title > sign up</title > </head > <body > <h1 > Sign Up</h1 > <hr > <form th:action ="@{/user}" method ="POST" > email : <input type ="text" name ="email" > <br > password : <input type ="password" name ="password" > <br > <input type ="radio" name ="auth" value ="ROLE_ADMIN,ROLE_USER" > admin <input type ="radio" name ="auth" value ="ROLE_USER" checked ="checked" > user <br > <button type ="submit" > Join</button > </form > <br > <a href ="/login" > Go to login →</a > </body > </html >
sec:authentication 를 이용하면 권한, 이름 같이 현재 로그인된 정보를 확인할 수 있습니다. 로그아웃 역시 POST 로 csrf 를 붙여줍니다. (하지만 아까 GET /logout에 관한 컨트롤러 메소드를 만들어주었기 때문에 를 사용해도 무방합니다)