/* SecurityConfig.java 파일에서 PasswordEncode 객체를 Bean으로 등록했다.
* 해당 Bean을 테스트하여 암호가 인코딩되는지 확인하자
*/
package org.edwith.webbe.securityexam.service;
import org.edwith.webbe.securityexam.config.ApplicationConfig;
import org.edwith.webbe.securityexam.config.SecurityConfig;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
// 스프링 빈 컨테이너가 관리하는 빈을 테스트하려면 @RunWith와 @ContextConfiguration 어노테이션을 사용
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {ApplicationConfig.class, SecurityConfig.class})
public class PasswordEncoderTest {
@Autowired
PasswordEncoder passwordEncoder;
/* passwordEncode의 encode메소드를 이용해 문자열 "1234"를 인코딩한 후 출력(원래는 assert 메서드로 검사하는 것이 정석)
* 실행할 때마다 다른 결과가 나온다. 또한, 인코딩된 문자열을 원래 문자열로 바꿀수 없다.(단방향 암호화)
*/
@Test
public void passwordEncode() throws Exception{
System.out.println(passwordEncoder.encode("1234"));
}
@Test
public void passwordTest() throws Exception{
String encodePasswd = "$2a$10$xgWp2kXNabPQys6CBRShwOmz7f4/u6Gxf38XJkcGe/HHJak7t.Akm";
String password = "1234";
// 결과가 true이면 encodePasswd는 password가 인코딩된 문자열이라는 뜻
// Spring security는 내부적으로 matches() 메서드를 이용해서 검증을 수행
boolean test = passwordEncoder.matches(password, encodePasswd);
System.out.println(test);
}
}
package org.edwith.webbe.securityexam.config;
import org.edwith.webbe.securityexam.service.security.CustomUserDetailsService;
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.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;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// CustomUserDetailsService 객체 주입
@Autowired
CustomUserDetailsService customUserDetailsService;
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers(
"/webjars/**");
}
/* WebSecurityConfigurerAdapter가 가지고 있는 void configure(AuthenticationManagerBuilder auth)를 오버라이딩
* AuthenticationFilter가 아이디/암호를 입력해서 로그인 할 때 처리해주는 필터이고 아이디에 해당하는 정보를
* 데이터베이스에서 읽어 들일 때 UserDetailsService를 구현하고 있는 객체를 이용한다(Spring Security 개요 참조)
* UserDetailsService는 인터페이스이고, 해당 인터페이스를 구현하고 있는 Bean을 사용
* 주입된 CustomUserDetailsService객체를 auth.userDetailsService(customUserDetailsService)로 설정하고 있다.
* 이렇게 설정된 객체는 아이디/암호를 입력 받아 로그인을 처리하는 AuthenticationFilter에서 사용
* CustomUserDetailsService는 UserDetailsService를 구현하고 있는 객체여야 한다.
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(customUserDetailsService);
}
/* 로그인 과정없이 사용할 수 있는 경로 추가("/", "/main", "/members/loginerror", "/members/joinform", "/members/join", "/members/welcome")
* "/securepage", "/members/**"는 로그인도 되어 있어야 하고 "USER"권한도 가지고 있어야 접근할 수 있도록 설정
* 로그인 폼은 "/members/loginform"이 경로라는 것을 의미. 해당 경로가 요청 왔을 때 로그인 폼을 보여주는 컨트롤러 메소드를 작성해야 한다.
* 로그인 폼에서 input태그의 이름은 "userId", "password"이어야 한다는 설정을 하고 있다.
* ex. <input type="text" name="userId">, <input type="password" name="password">
* 아이디와 암호를 입력 받아 로그인 처리를 해주는 경로는 "/authenticate"로 설정
* "/authenticate" 경로는 직접 구현하는 것이 아니라, 아래와 같이 설정만 해주면
* Spring Security Filter가 해당 경로를 검사하다가 아이디가 전달되면 로그인을 처리해준다.
* <form method="post" action="/securityexam/authenticate"> 와 같이 action 설정해야한다.
* 프로젝트의 Context Path가 "/securityexam"이기 때문에 "/securityexam/authenticate"이다.
* 만약 로그인 처리가 실패하게 되면 "/loginerror?login_error=1"로 forwarding 된다.
* 해당 경로를 처리하는 컨트롤러 메소드는 개발자가 작성해야한다.
* 로그인을 성공하게 되면 "/"로 redirect 한다.
* permitAll()이 붙어 있다는 것은 해당 로그인 폼이 아무나 접근 가능하다는 것을 의미한다.(로그인 페이지를 로그인 후에 접근할 수는 없으므로)
* "/logout"요청이 오면 세션에서 로그인 정보를 삭제한 후 "/"로 redirect
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/", "/main", "/members/loginerror", "/members/joinform", "/members/join", "/members/welcome").permitAll()
.antMatchers("/securepage", "/members/**").hasRole("USER")
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/members/loginform")
.usernameParameter("userId")
.passwordParameter("password")
.loginProcessingUrl("/authenticate")
.failureForwardUrl("/members/loginerror?login_error=1")
.defaultSuccessUrl("/",true)
.permitAll()
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/");
}
@Bean
public PasswordEncoder encoder() {
return new BCryptPasswordEncoder();
}
}
아이디와 암호를 전달받아 로그인을 처리하는 것은 AuthenticationFilter이다. AuthenticationFilter는 아이디에 해당하는 정보를 읽어 들이기 위해 UserDetailsService인터페이스를 구현하는 빈(Bean)을 사용한다.
UserDetailsService 인터페이스는 스프링 시큐리티에서 제공한다. 해당 인터페이스를 구현한다는 것은 스프링 시큐리티와 밀접한 연관을 맺는다는 것을 의미한다.
그런데, 사용자 정보를 읽어들이는 부분은 스프링 시큐리티와 상관 없을 수도 있다.
즉, 스프링 시큐리티 관련 부분과 회원 정보를 다루는 부분을 분리하기 위해 다음과 같은 구조로 인터페이스와 클래스를 작성하도록 하자.
package org.edwith.webbe.securityexam.service.security;
public class UserEntity {
private String loginUserId;
private String password;
public UserEntity(String loginUserId, String password) {
this.loginUserId = loginUserId;
this.password = password;
}
public String getLoginUserId() {
return loginUserId;
}
public void setLoginUserId(String loginUserId) {
this.loginUserId = loginUserId;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
@Override
public String toString() {
return "UserEntity [loginUserId=" + loginUserId + ", password=" + password + "]";
}
}
package org.edwith.webbe.securityexam.service.security;
public class UserRoleEntity {
private String userLoginId;
private String roleName;
public UserRoleEntity(String userLoginId, String roleName) {
this.userLoginId = userLoginId;
this.roleName = roleName;
}
public String getUserLoginId() {
return userLoginId;
}
public void setUserLoginId(String userLoginId) {
this.userLoginId = userLoginId;
}
public String getRoleName() {
return roleName;
}
public void setRoleName(String roleName) {
this.roleName = roleName;
}
@Override
public String toString() {
return "UserRoleEntity [userLoginId=" + userLoginId + ", roleName=" + roleName + "]";
}
}
package org.edwith.webbe.securityexam.service.security;
import java.util.List;
public interface UserDbService {
public UserEntity getUser(String loginUserId);
public List<UserRoleEntity> getUserRoles(String loginUserId);
}
package org.edwith.webbe.securityexam.service.security;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
public class CustomUserDetails implements UserDetails {
private String username;
private String password;
private boolean isEnabled;
private boolean isAccountNonExpired;
private boolean isAccountNonLocked;
private boolean isCredentialsNonExpired;
private Collection<? extends GrantedAuthority>authorities;
@Override
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
@Override
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
@Override
public boolean isEnabled() {
return isEnabled;
}
public void setEnabled(boolean enabled) {
isEnabled = enabled;
}
@Override
public boolean isAccountNonExpired() {
return isAccountNonExpired;
}
public void setAccountNonExpired(boolean accountNonExpired) {
isAccountNonExpired = accountNonExpired;
}
@Override
public boolean isAccountNonLocked() {
return isAccountNonLocked;
}
public void setAccountNonLocked(boolean accountNonLocked) {
isAccountNonLocked = accountNonLocked;
}
@Override
public boolean isCredentialsNonExpired() {
return isCredentialsNonExpired;
}
public void setCredentialsNonExpired(boolean credentialsNonExpired) {
isCredentialsNonExpired = credentialsNonExpired;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
public void setAuthorities(Collection<? extends GrantedAuthority> authorities) {
this.authorities = authorities;
}
@Override
public String toString() {
return "CustomUserDetails [username=" + username + ", password=" + password + ", isEnabled=" + isEnabled
+ ", isAccountNonExpired=" + isAccountNonExpired + ", isAccountNonLocked=" + isAccountNonLocked
+ ", isCredentialsNonExpired=" + isCredentialsNonExpired + ", authorities=" + authorities + "]";
}
}
package org.edwith.webbe.securityexam.service.security;
import java.util.ArrayList;
import java.util.List;
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.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class CustomUserDetailsService implements UserDetailsService {
// UserDbService는 인터페이스다. 해당 인터페이스를 구현하고 있는 객체가 Bean으로 등록되어 있어야 한다.
@Autowired
UserDbService userdbService;
// 사용자가 로그인할 때 아이디를 입력하면 loadUserByUsername에 인자로 전달한다.
@Override
public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException {
// loginId에 해당하는 정보를 데이터베이스에서 읽어 CustomUser객체에 저장한다.
UserEntity customUser = userdbService.getUser(loginId);
// 해당 아이디에 해당하는 정보가 없으면 UsernameNotFoundException이 발생
if(customUser == null)
throw new UsernameNotFoundException("사용자가 입력한 아이디에 해당하는 사용자를 찾을 수 없습니다.");
// 정보가 있을 경우엔 UserDetails인터페이스를 구현한 객체를 리턴
CustomUserDetails customUserDetails = new CustomUserDetails();
customUserDetails.setUsername(customUser.getLoginUserId());
customUserDetails.setPassword(customUser.getPassword());
List<UserRoleEntity> customRoles = userdbService.getUserRoles(loginId);
// 로그인 한 사용자의 권한 정보를 GrantedAuthority를 구현하고 있는 SimpleGrantedAuthority객체에 담아
// 리스트에 추가한다. MemberRole 이름은 "ROLE_"로 시작되야 한다.
List<GrantedAuthority> authorities = new ArrayList<>();
if(customRoles != null) {
for (UserRoleEntity customRole : customRoles) {
authorities.add(new SimpleGrantedAuthority(customRole.getRoleName()));
}
}
// CustomUserDetails객체에 권한 목록 (authorities)를 설정한다.
customUserDetails.setAuthorities(authorities);
customUserDetails.setEnabled(true);
customUserDetails.setAccountNonExpired(true);
customUserDetails.setAccountNonLocked(true);
customUserDetails.setCredentialsNonExpired(true);
return customUserDetails;
}
}
package org.edwith.webbe.securityexam.service;
import org.edwith.webbe.securityexam.service.security.UserDbService;
public interface MemberService extends UserDbService {
}
package org.edwith.webbe.securityexam.service;
import java.util.ArrayList;
import java.util.List;
import org.edwith.webbe.securityexam.service.security.UserEntity;
import org.edwith.webbe.securityexam.service.security.UserRoleEntity;
import org.springframework.stereotype.Service;
@Service
public class MemberServiceImpl implements MemberService {
@Override
public UserEntity getUser(String loginUserId) {
return new UserEntity("carami", "$2a$10$G/ADAGLU3vKBd62E6GbrgetQpEKu2ukKgiDR5TWHYwrem0cSv6Z8m");
}
@Override
public List<UserRoleEntity> getUserRoles(String loginUserId) {
List<UserRoleEntity> list = new ArrayList<>();
list.add(new UserRoleEntity("carami", "ROLE_USER"));
return list;
}
}
package org.edwith.webbe.securityexam.controller;
import org.edwith.webbe.securityexam.service.MemberService;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
@RequestMapping(path = "/members")
public class MemberController {
// 스프링 컨테이너가 생성자를 통해 자동으로 주입한다.
private final MemberService memberService;
public MemberController(MemberService memberService){
this.memberService = memberService;
}
@GetMapping("/loginform")
public String loginform(){
return "members/loginform";
}
@RequestMapping("/loginerror")
public String loginerror(@RequestParam("login_error")String loginError){
return "members/loginerror";
}
}
<%@ page contentType="text/html; charset=utf-8" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%>
<!DOCTYPE html>
<html>
<head>
<title>로그인 </title>
</head>
<body>
<div>
<div>
<form method="post" action="/securityexam/authenticate">
<div>
<label>ID</label>
<input type="text" name="userId">
</div>
<div>
<label>암호</label>
<input type="password" name="password">
</div>
<div>
<label></label>
<input type="submit" value="로그인">
</div>
</form>
</div>
</div>
</body>
</html>
<%@ page contentType="text/html; charset=utf-8" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%>
<!DOCTYPE html>
<html>
<head>
<title>로그인 오류</title>
</head>
<body>
<h1>로그인 오류가 발생했습니다. id나 암호를 다시 입력해주세요.</h1>
<a href="/securityexam/members/loginform">login</a>
</body>
</html>