이번엔 지난번에 완성했던 Customize Form에 적용했던 Spring Security에 완성도를 더하겠습니다. 바로 Authorization과 Encryption 입니다. 권한과 암호화는 접근하기 힘들것 같지만 의외로 어렵지 않습니다. 그럼 바로 시작해 보겠습니다. 구현은 아래의 절차로 진행하겠습니다.
- 관련 DB table 생성 (Role_Info, User_Role)
- 회원가입 페이지 생성 및 관련 API 구현
- View 페이지 구성
- 입력된 비밀번호는 암호화 적용하여 저장
- 로그인 시 Role 부여
1. 관련 DB table 생성
Authorization 구현을 위해서는 권한정보를 저장하는 Table... 사용자와 권한을 매핑하는 Table이 필요합니다. 그래서 2개의 Table을 추가하도록 하겠습니다.
[role_info]
CREATE TABLE role_info (
ROLE_ID BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
ROLE_NAME VARCHAR(50) NOT NULL,
DESCRIPTION VARCHAR(500),
USE_YN VARCHAR(1),
CREATE_USER_ID BIGINT NOT NULL,
CREATE_DATE TIMESTAMP NOT NULL,
UPDATE_USER_ID BIGINT,
UPDATE_DATE TIMESTAMP
);
INSERT INTO role_info
(ROLE_NAME, DESCRIPTION, USE_YN, CREATE_USER_ID, CREATE_DATE)
VALUES
('ROLE_ADMIN', 'This is Admin Role', 'Y', 1, CURRENT_TIMESTAMP);
[user_role]
CREATE TABLE user_role (
USER_ID BIGINT NOT NULL,
ROLE_ID BIGINT NOT NULL,
CREATE_DATE TIMESTAMP NOT NULL
);
user_role에 데이터 insert는 뒤에 sing up 페이지에서 신규 회원을 가입하고, 강제로 하겠습니다. 이번에는 권한을 넣는 페이지까지는 제작하지 않겠습니다.
2. 회원가입 페이지 생성 및 관련 API 구현
지금까지 그래도 여러가지의 API를 구현해 봤기 때문에, 구상한 로직으로 바로 view페이지 제작 및 API를 구현해 보도록 하겠습니다. 그렇다면 생성하거나 건드려야 하는 파일들은... html신규, Controller신규, service추가, mapper추가 정도가 되겠습니다.
[signup.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>Signup Example </title>
</head>
<body>
<h1 th:inline="text">Signup page</h1>
<form th:action="@{/usersignup}" method="post">
<div><label> User Name : <input type="text" name="loginId"/> </label></div>
<div><label> Password: <input type="password" name="loginPass"/> </label></div>
<div><label> FUll Name : <input type="text" name="fullName"/> </label></div>
<div><label> Email : <input type="text" name="email"/> </label></div>
<div><input type="submit" value="Sign Up"/></div>
</form>
</body>
</html>
[ViewController.java]
@Controller
public class ViewController {
final Logger L = LoggerFactory.getLogger(this.getClass());
@Autowired
MyUserDetailsService myUserDetailsService;
@PostMapping("/usersignup")
public String signupUser(UserDTO user) throws Exception {
L.info("[POST] /usersignup :: Insert User in user_info table - {}", user);
System.out.println(user);
myUserDetailsService.signupUser(user);
return "redirect:/home";
}
}
위의 html내 form에서 요청하는 주소를 controller에 구성합니다. 단, 기존에 만들었던 @RestController와 달리 REST방식이 아닌, 페이지 이동등의 로직을 처리해야 하기 때문에 @Controller 어노테이션을 추가합니다.
[UserDTO.java 수정]
public class UserDTO {
private String loginId;
private String loginPass;
private String fullName;
private String email;
public String getLoginId() {
return loginId;
}
public void setLoginId(String loginId) {
this.loginId = loginId;
}
public String getLoginPass() {
return loginPass;
}
public void setLoginPass(String loginPass) {
this.loginPass = loginPass;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
}
[MyUserDetailsService.java]
@Service
public class MyUserDetailsService implements UserDetailsService {
@Autowired
UserInfoMapper userInfoMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
MyUserDetails mud = userInfoMapper.getUserInfoById(username);
return mud;
}
public void signupUser(UserDTO user) {
userInfoMapper.signupUser(user);
}
}
[UserInfoMapper.java]
public interface UserInfoMapper {
MyUserDetails getUserInfoById(@Param("username") String username);
void signupUser(@Param("user") UserDTO user);
}
[UserInfoMapper.xml]
<insert id="signupUser" parameterType="com.example.ayoteralab.main.dto.UserDTO">
<![CDATA[
INSERT INTO user_info
(LOGIN_ID, LOGIN_PASS, FULL_NAME, EMAIL, CREATE_DATE, LAST_LOGIN_PASS_UPDATE_DATE, DELETE_YN, LOGIN_FAIL_COUNT)
VALUES
(#{user.loginId}, #{user.loginPass}, #{user.fullName}, #{user.email}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'N', 0);
]]>
</insert>
여기까지 구성이 완료 됩니다.
이렇게 하면... application을 실행하고 localhost/signup 의 경로로 접속을 시도합니다. 하지만 아무리 해도 지속적으로 아래의 로그인요청 화면만 발생합니다.
그 이유는... SecurityConfiguration에서 확인 할 수 있습니다.
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/", "/home").permitAll()
.anyRequest().authenticated();
바로 이 블럭에서... /home와 root를 제외한 나머지는 무조건 인증이 필요하다고 했기 때문입니다. 그렇다면 이 부분에 관련 path를 추가해야 합니다.
[SecurityConfiguration.java]
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/", "/home", "/signup", "/usersignup").permitAll()
.anyRequest().authenticated();
http.formLogin()
.loginPage("/userlogin")
// .usernameParameter("id")
// .passwordParameter("pass")
.defaultSuccessUrl("/#/todo")
.permitAll();
http.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/home")
.invalidateHttpSession(true)
.permitAll();
http.exceptionHandling().accessDeniedPage("/user/deny");
}
이번에는 404 error가 발생합니다.
이유가 무엇일까요?? WebConfig에 해당 페이지에 대한 연동 정보가 없기 때문입니다. WebConfig대신 controller를 이용하는 부분은 뒤에 더 다루어 보겠습니다.
[WebConfiguration.java 에 추가]
registry.addViewController("/signup").setViewName("signup");
이제 정상적으로 접근이 됩니다. 그럼 테스트로 하나 계정을 생성해 보겠습니다. DB에는 아래과 같이 저장이 되고, redirect로 /home으로 했기 때문에 정상적으로 /home으로 이동합니다. 그럼... 당연히 login을 하고 신규 정보로 입력하면 정상적으로 로그인이 됩니다.
3. View 페이지 구성
회원가입부터 로그인, 권한 별 페이지 구성을 위해서 아래와 같이 view 페이지를 구성하겠습니다.
기존에 만들었던 내용에서 변경되는 부분은 userlogin.html에 signup.html로 가기위한 링크를 하나 추가합니다. 그리고 권한 별 접근 여부를 확인하기 위해서 usermain / adminmain을 새로 만들겠습니다.
[userlogin.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}">
Invalid username and password.
</div>
<div th:if="${param.logout}">
You have been logged out.
</div>
<form th:action="@{/userlogin}" method="post">
<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="Sign In"/></div>
</form>
<p>Click <a th:href="@{/signup}">here</a> to signup.</p>
</body>
</html>
[hello.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>
<h1 th:inline="text">Welcome Page [[${#httpServletRequest.remoteUser}]]!</h1>
<p>Click User<a th:href="@{/user/main}">here</a></p>
<p>Click Admin<a th:href="@{/admin/main}">here</a></p>
<form th:action="@{/logout}" method="post">
<input type="submit" value="Sign Out"/>
</form>
</body>
</html>
[usermain.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>
<h1>User Main!</h1>
<p>Back <a th:href="@{/hello}">here</a></p>
</body>
</html>
[adminmain.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>
<h1>Admin Main!</h1>
<p>Back <a th:href="@{/hello}">here</a></p>
</body>
</html>
[WebConfiguration.java]
package com.example.ayoteralab.main.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/home").setViewName("home");
registry.addViewController("/").setViewName("home");
registry.addViewController("/hello").setViewName("hello");
registry.addViewController("/userlogin").setViewName("userlogin");
registry.addViewController("/signup").setViewName("signup");
registry.addViewController("/user/main").setViewName("usermain");
registry.addViewController("/admin/main").setViewName("adminmain");
}
}
해당 링크 경로와 html 파일을 매핑시켜 줍니다. 그리고 시도를 하면...
정상적으로 로그인이 되고, 각 권한별 체크를 위한 링크와 연결된 페이지의 접속을 시도하게 되면... 정상적으로 가능합니다. 왜냐하면, 해당 페이지에 대한 Role권한 설정을 하지 않았기 때문입니다.
우선은 view구성이 정상적으로 되었으니, 다음으로 넘어가 보겠습니다.
4. 비밀번호 암호화 저장
비밀번호 암호화 저장은 은근히 간단합니다. 방법은 form에서 받는 내용은 동일하지만, service에서 form을 통해 받은 비밀번호를 BCryptPasswordEncoder를 이용해서 암호화 하여 DTO에 저장하고... 이를 mapper를 통해서 암호화된 pass를 저장하는 방식입니다. 이를 위해서 아래의 내용을 반영합니다.
[UserDTO.java]
private String loginPassEncrypt;
public String getLoginPassEncrypt() {
return loginPassEncrypt;
}
public void setLoginPassEncrypt(String loginPassEncrypt) {
this.loginPassEncrypt = loginPassEncrypt;
}
[MyUserDetailsService.java]
@Service
public class MyUserDetailsService implements UserDetailsService {
@Autowired
UserInfoMapper userInfoMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
MyUserDetails mud = userInfoMapper.getUserInfoById(username);
return mud;
}
public void signupUser(UserDTO user) {
//password encryption
BCryptPasswordEncoder passEncoder = new BCryptPasswordEncoder();
String encryptPass = passEncoder.encode(user.getLoginPass());
user.setLoginPassEncrypt(encryptPass);
userInfoMapper.signupUser(user);
}
}
BCryptPasswordEncoder 클래스를 통해 form으로 받은 string을 암호화 하여, 신규 변수에 넣습니다. 그리고 바로 mapper.xml로 가서 신규 변수를 기존 pass가 들어가는 자리에 매핑합니다.
[UserInfoMapper.xml]
<insert id="signupUser" parameterType="com.example.ayoteralab.main.dto.UserDTO">
<![CDATA[
INSERT INTO user_info
(LOGIN_ID, LOGIN_PASS, FULL_NAME, EMAIL, CREATE_DATE, LAST_LOGIN_PASS_UPDATE_DATE, DELETE_YN, LOGIN_FAIL_COUNT)
VALUES
(#{user.loginId}, #{user.loginPassEncrypt}, #{user.fullName}, #{user.email}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'N', 0);
]]>
</insert>
기존에 #{user.loginPass} => #{user.loginPassEncrypt}로 변경합니다. 이게 끝이죠?? 그럼 암호화 해서 들어가는지 테스트를 해보겠습니다.
기존에 영문으로 들어갔던 부분이 정상적으로 암호화 해서 들어갔습니다. 전 똑같이 test라고 입력했는데 말이죠~
그럼 암호화되어 저장된다면, 향후에 form을 통해서 plain pass가 들어온다면 당연히 비인증된 고객이라고 할 겁니다. 신규로 입력한 정보로 한번 시도해 보겠습니다.
이렇게 발생하는 이유는... 현재 MyAuthenticationProvider.java에서는 test로 입력하고 DB에서는 암호화된 test가 들어가 있어서 불일치가 나는 것입니다. 그럼 소스의 수정이 필요하겠죠??
[MyAuthenticationProvider.java]
@Component
public class MyAuthenticationProvider implements AuthenticationProvider {
@Autowired
MyUserDetailsService myUserDetailsService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String loginId = (String) authentication.getPrincipal();
String loginPass = (String) authentication.getCredentials();
System.out.println(loginId);
System.out.println(loginPass);
System.out.println(authentication);
MyUserDetails mud = (MyUserDetails) myUserDetailsService.loadUserByUsername(loginId);
BCryptPasswordEncoder passEncoder = new BCryptPasswordEncoder();
Boolean matchPass = passEncoder.matches(loginPass, mud.getPassword());
if(mud == null || !matchPass) return null;
//(principal, credentials, authorities)
return new UsernamePasswordAuthenticationToken(loginId, loginPass, null);
}
@Override
public boolean supports(Class<?> authentication) {
return true;
}
}
바뀐 부분은 BCryptPasswordEncoder를 추가하고, 제공하는 메서드 중에 matches를 활용해서 입력된 plain pass가 DB에 저장된 암호화 pass와 동일한지를 Boolean으로 리턴받습니다.
당연히 혹자는 입력받는 pass를 암호화 하고, db내 암호화된 정보를 equals로 비교하면 안되냐고 생각하시겠지만... encoder로 암호화 되는 부분은 매번 암호화 할때마다 달라져서 equals로 비교하면 인증에 실패하게 됩니다.
(이 부분은 나중에 실험을 통해서 확인해 보겠습니다.)
이렇게 하고, 시도를 해보면~!!
새로만든 암호화 된 계정으로 정상적으로 접속이 됩니다.
5. 로그인 시 Role 부여
Role을 부여하여 Athorization을 처리하는 방법은... 우선 user_role을 추가하고 service를 통해 해당 user의 role이름을 가져오는 로직을 구현합니다. 그리고 페이지 별 role을 SecurityConfiguration에 추가해줍니다.
[user_role 에 role 추가]
INSERT INTO user_role
(USER_ID, ROLE_ID)
VALUES
(3, 1);
[MyUserDetails.java]
private Integer userId;
public Integer getUserId() {
return userId;
}
public void setUserId(Integer userId) {
this.userId = userId;
}
앞으로의 mapper에서 userid로 검색을 하기위해서, MyUserDetails에 해당 변수를 추가합니다. 그 다음에는 Service를 구현합니다.
[MyUserDetailsService.java]
public ArrayList<String> getUserRoleByUserId(Integer userId) {
return userInfoMapper.getUserRoleByUserId(userId);
}
[UserInfoMapper.xml 추가]
<select id="getUserRoleByUserId" resultType="java.lang.String">
<![CDATA[
SELECT
B.ROLE_NAME
FROM user_role A
LEFT JOIN role_info B
ON A.ROLE_ID = B.ROLE_ID
where A.USER_ID = #{userId}
]]>
</select>
마지막으로 가져온 권한을 주입하여, 보내는 로직을 구현합니다.
[MyAuthenticationProvider.java 에 추가]
@Component
public class MyAuthenticationProvider implements AuthenticationProvider {
@Autowired
MyUserDetailsService myUserDetailsService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String loginId = (String) authentication.getPrincipal();
String loginPass = (String) authentication.getCredentials();
System.out.println(loginId);
System.out.println(loginPass);
System.out.println(authentication);
MyUserDetails mud = (MyUserDetails) myUserDetailsService.loadUserByUsername(loginId);
BCryptPasswordEncoder passEncoder = new BCryptPasswordEncoder();
Boolean matchPass = passEncoder.matches(loginPass, mud.getPassword());
if(mud == null || !matchPass) return null;
//권한 가져오는 로직
ArrayList<String> userRole = myUserDetailsService.getUserRoleByUserId(mud.getUserId());
ArrayList<GrantedAuthority> authorities = new ArrayList<>();
for(String eachRole : userRole) {
authorities.add(new SimpleGrantedAuthority(eachRole));
}
//(principal, credentials, authorities)
return new UsernamePasswordAuthenticationToken(loginId, loginPass, authorities);
}
@Override
public boolean supports(Class<?> authentication) {
return true;
}
}
우선... 바로전에 Service에 추가한 메서드를 호출하여, user가 가진 권한 목록을 가져옵니다. 그 다음에는 해당 권한 목록을 GrantedAuthority로 추가해준 후 마지막으로 Authentication을 리턴할때 추가하여 리턴해줍니다. 그럼 아까와 달리 Admin만 들어가지고 User는 안들어가 지는지 확인해보면 되겠습니다.
아참!! 그전에 SecurityConfiguration에 해당 페이지에 Role을 부여하여, 접근권한을 설정합니다.
[SecurityConfiguration.java]
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
MyAuthenticationProvider myAuthenticationProvider;
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/static/css/**, /static/js/**, *.ico");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/", "/home", "/signup", "/usersignup").permitAll()
.antMatchers("/user", "/user/**").hasRole("USER")
.antMatchers("/admin", "/admin/**").hasRole("ADMIN")
.anyRequest().authenticated();
http.formLogin()
.loginPage("/userlogin")
// .usernameParameter("id")
// .passwordParameter("pass")
.defaultSuccessUrl("/#/todo")
.permitAll();
http.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/home")
.invalidateHttpSession(true)
.permitAll();
http.exceptionHandling().accessDeniedPage("/user/deny");
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(myAuthenticationProvider);
}
}
기존과 달라진 부분은 .hasRole을 추가하여 접근권한을 설정했습니다. 그렇다면 /admin/main은 ROLE_ADMIN을 가진 사람만 접근이 가능해질 것입니다. 하지만 Security에 설정한 .hasRole의 권한명은 "ADMIN"이고, DB에 저장된 이름은 앞에 "ROLE_" 이 붙는데 왜 가능한걸까요?? 이것도 별도로 테스트 해보겠습니다~!!
Admin만 정상으로 뜨고, User는 404가 발생합니다.
이렇게 장편의 내용이 완성되었습니다. Spring Security를 Form형태로 구현하고자 하는 분들께 도움이 되면 좋겠습니다.
-Ayotera Lab-
댓글