이번 포스팅에서는 사용자가 회원 가입할 때 데이터베이스에 암호화된 비밀번호를 저장하여 보안 강도를 강력하게 높여주는 방법을 다룰 것이다.
아이디 : test1, 원본 패스워드 : 1234
("1234"가 위와 같이 복잡한 형태로 암호화된 모습)
소스 코드를 소개하기 앞서 암호화 방식에 대해 설명하고자 한다.
Spring Security에서 제공하는 BCryptPasswordEncoder 클래스를 사용할 것인데,
BCryptPasswordEncoder 클래스는 단방향 암호화를 제공하며, 패스워드 원문이 같더라도 암호화 할 때마다 값이 달라진다는 특징이 있다. 그 만큼 보안이 매우 강력하다.
※ 단방향 암호화 : 복호화가 불가능한 암호화 방식.
그렇다면 사용자도 암호화된 패스워드를 일일이 쳐서 로그인 해야하는 것인가?
그렇지 않다. 암호화 할때마다 값이 수시로 바뀌기 때문에 물론 두 암호 문자열을 단순히 비교하는건 불가능하나,
대신 BCryptPasswordEncoder 클래스가 제공하는 matches 메소드를 사용하면 된다.
예제 프로젝트의 구성은 이렇다.
이번에도 변경되거나 새로 생성한 소스 파일 위주로 설명하도록 할것이며, 해당 파일은 빨간 상자로 표시해두었다.
1. 회원 가입
1) 회원 가입 VIEW
<signUpView.jsp>
사용자로부터 아이디와 비밀번호를 입력받아 form을 signUp.html로 전달한다.
<%@ page language="java" contentType="text/html" pageEncoding="UTF-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<%@ page session="false" %>
<html>
<head>
</head>
<body>
<h1>
Sign up
</h1>
<form action="signUp.html" method="post">
ID : <input type="text" name="id"><br>
PW : <input type="password" name="password"><br>
<input type="submit" value="회원등록">
</form>
</body>
</html>
2) 데이터베이스 테이블 수정
User 계정 정보가 담겨있는 테이블에서 비밀번호 필드 설정을 수정해야 한다.
Spring Security(3) 포스팅에서 이 테이블을 만들었는데, 비밀번호를 최대 300자로 지정했었다.
검색해본 결과 BCryptPasswordEncoder 클래스를 사용할 것이면, 60자로 설정하는 것이 안정적이라고 한다.
특정 필드에 대한 설정을 바꾸기 위해 ALTER문을 이용한다.
ALTER TABLE USERACC MODIFY(PASSWORD CHAR(60));
3) 데이터베이스(Mybatis) 관련 파일 수정
회원 가입, 권한 부여를 위한 userSignUp, giveAuth 메소드 2개를 추가했다.
<Dao.java> (com.example.demo)
package com.example.demo;
import java.util.ArrayList;
public interface Dao {
public UserDetailsDto selectUser(String username);
public ArrayList<String> getAuthList(String username);
public void userSignUp(String username, String password, char enable);
public void giveAuth(String username, String authority);
}
<Dao.xml> (com.example.demo.mapper)
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Config 3.1//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.Dao">
<select id="selectUser" resultType="com.example.demo.UserDetailsDto">
SELECT * FROM USERACC WHERE ID=#{param1}
</select>
<select id="getAuthList" resultType="String">
SELECT AUTHORITY FROM USERAUTH WHERE ID=#{param1}
</select>
<insert id="userSignUp">
INSERT INTO USERACC VALUES(#{param1},#{param2},#{param3})
</insert>
<insert id="giveAuth">
INSERT INTO USERAUTH VALUES(#{param1},#{param2})
</insert>
</mapper>
4) BCryptPasswordEncoder 빈 생성
<servlet-context.xml(일부 코드)>
Spring Security에서 제공하는 BCryptPasswordEncoder 클래스를 그대로 가져와 쓸 것이기 때문에 bean을 생성하는 코드를 직접 추가해준다.
<beans:bean id="bPasswordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"/>
5) 회원 가입 로직
<HomeController.java(일부 코드)>
Controller에 signUpView.jsp 접근을 위한 signUpView 메소드를 추가했고, 회원 가입을 담당하는 signUp 메소드를 추가했다.
그리고 회원 가입에 성공했다면 login.jsp로, 실패했다면 signUpView.jsp로 이동시켜주기로 했다.
잠시 후에 다룰 SignUpService 클래스는 signUpService bean 객체의 소속 클래스이며, 사용자가 입력한 정보를 데이터베이스에 넣는 역할을 수행한다.
@Autowired
SignUpService signUpService;
@RequestMapping("/signUpView.html")
public String signUpView(Model model) {
return "signUpView";
}
@RequestMapping("/signUp.html")
public String signUp(HttpServletRequest request, Model model) {
//사용자가 입력한 정보를 파라미터로 넘김
boolean isInserted = signUpService.insertUserInfo(request.getParameter("id"), request.getParameter("password"));
if(isInserted) return "login";
else return "signUpView";
}
<SignUpService.java>
회원 가입을 위해 데이터베이스에 접근하는 클래스이다.
먼저, Context 설정 파일에서 만들어준 sqlSessionTemplate, bPasswordEncoder bean을 @Autowired 어노테이션으로 가져왔다.
insertUserInfo 메소드에서는 ID(username)로 유저를 검색해서 이미 해당 ID를 가지는 유저가 있다면 false를, 중복되는 유저가 없다면 데이터 베이스에 추가하고 true를 리턴해서 추가 성공 여부를 반환시킨다.
여기서 제일 중요한 것이 BCryptPasswordEncoder 클래스의 encode 메소드인데, 인자로 사용자가 입력한 패스워드 문자열을 넣어 암호화한 값을 반환한다.
package com.example.demo;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
//이 클래스에 대한 bean 객체 만들기
@Service("signUpService")
public class SignUpService {
@Autowired
private BCryptPasswordEncoder bPasswordEncoder;
@Autowired
private SqlSessionTemplate sqlSession;
public boolean insertUserInfo(String username, String password) {
Dao dao = sqlSession.getMapper(Dao.class);
UserDetailsDto user = dao.selectUser(username);
if (user != null) {
return false;
}
else {
dao.userSignUp(username,bPasswordEncoder.encode(password), '1'); //유저 등록
dao.giveAuth(username, "ROLE_USER"); //기본 권한 부여
return true;
}
}
}
2. 인증
1) Security 설정 파일
<security-context.xml>
우리는 provider을 커스터마이징 할 것이기 때문에 속성으로 user-service-ref가 아닌 ref를 써야 하며, loginService bean 객체 대신 loginAuthenticationProvider bean 객체를 참조했다.
커스터마이징을 하지 않는다면 Spring Security는 원본 패스워드로 직접 인증을 수행해버려서 사용자는 로그인을 할 수 없게 된다.
그럼 loginService는 이제 쓸 일이 없는 것일까?
그렇지 않다.
loginService 객체는 DB에서 유저 레코드를 DTO객체로 가져오는 역할을 하기 때문에 반드시 필요하다.
그래서 loginAuthenticationProvider 객체 내에서 반드시 필요하여 다시 사용 될 것이다.
수정 전
<s:authentication-manager>
<s:authentication-provider user-service-ref="loginService"/>
</s:authentication-manager>
수정 후
<s:authentication-manager>
<s:authentication-provider ref="loginAuthenticationProvider"/>
</s:authentication-manager>
2) AuthenticationProvider (loginAuthenticationProvider)
<LoginAuthenticationProvider.java>
Provider을 커스터마이징한 클래스이다.
먼저, loginService bean과 Context 설정 파일에서 만들어준 bPasswordEncoder bean을 가져왔다.
방금 언급한 loginService 객체를 여기서 다시 사용하게 되는 것이다.
AuthenticationProvider 인터페이스를 상속받아 필수적으로 가져온 두 메소드 authenticate, supports의 역할을 살펴보자.
authenticate 메소드 : 로그인 인증을 담당하는 메소드이다. 커스터마이징을 하기 위해 가져온 메소드이기 때문에, 인증 절차와 예외 처리 작업을 우리가 직접 해야한다.
authenticate의 파라미터인 Authentication 객체에는 사용자가 입력한 정보가 들어있는데, 여기서 아이디와 비밀번호를 가져오고, 가져온 아이디를 이용해 데이터베이스로부터 DTO를 가져오면 인증을 시작할 준비가 된 것이다.
여기서 가장 중요한 메소드는 BCryptPasswordEncoder 클래스의 matches 메소드이다. 인자로 패스워드 원본과 DB에 암호화된 패스워드를 넣어 일치 여부를 확인한다.
예외 처리 관련 부분은 주석을 참고하면 될 것이고, 예외가 발생했을 때 LoginFailHandler로 예외를 넘겨주기 위해 AuthenticationException 객체를 throws 대상 객체로 지정했다는 것을 꼭 알아두자.
로그인에 성공했기 때문에 보안을 위해 DTO 객체의 PASSWORD를 null로 바꿔주고 UsernamePasswordAuthenticationToken형의 Authentication 구현체를 최종적으로 반환하면 되는데,
UsernamePasswordAuthenticationToken의 인자는 각각 아이디, 패스워드, 권한이며, 패스워드는 null로 설정 해서 암호화된 패스워드도 비공개 처리할 수 있다.
supports 메소드 : 반환 객체 타입을 검사해주며, true를 리턴받아야 정상적으로 진행된다.
package com.example.demo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AccountExpiredException;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
//이 클래스에 대한 bean 객체 만들기
@Service("loginAuthenticationProvider")
public class LoginAuthenticationProvider implements AuthenticationProvider{
@Autowired
UserDetailsService loginService;
@Autowired
BCryptPasswordEncoder bPasswordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
//DB에서 유저 레코드를 DTO객체로 가져오기
String userId = authentication.getName();
String userPw = (String) authentication.getCredentials();
UserDetailsDto userDetailsDto = (UserDetailsDto) loginService.loadUserByUsername(userId);
//LoginFailHandler로 오류를 던짐
if (userDetailsDto == null || !userId.equals(userDetailsDto.getUsername())
|| !bPasswordEncoder.matches(userPw, userDetailsDto.getPassword())) {
throw new BadCredentialsException(userId); //아이디, 비밀번호 불일치
} else if(!userDetailsDto.isEnabled()) {
throw new DisabledException(userId); //계정 비활성화
} else if(!userDetailsDto.isAccountNonLocked()) {
throw new LockedException(userId); //계정 잠김
} else if (!userDetailsDto.isAccountNonExpired()) {
throw new AccountExpiredException(userId); //계정 만료
} else if (!userDetailsDto.isCredentialsNonExpired()) {
throw new CredentialsExpiredException(userId); //비밀번호 만료
}
//로그인 성공
userDetailsDto.setPassword(null);
Authentication newAuth = new UsernamePasswordAuthenticationToken(userId,null,userDetailsDto.getAuthorities());
return newAuth;
}
//반환 객체 타입 검사
@Override
public boolean supports(Class<?> authentication) {
// TODO Auto-generated method stub
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
3) UserDetails(DTO)객체 수정
<UserDetailsDto.java(일부 코드)>
Provider에서 로그인에 성공한 뒤, 해당 객체의 비밀번호를 null로 설정할 수 있도록 setter 메소드를 추가했다.
public void setPassword(String password) {
PASSWORD = password;
}
참고) UserDetailsDto.java 풀 소스
package com.example.demo;
import java.util.ArrayList;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
public class UserDetailsDto implements UserDetails{
private String ID;
private String PASSWORD;
private boolean ENABLED;
private ArrayList<GrantedAuthority> authority;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authority;
}
public void setAuthority(ArrayList<String> authList) {
ArrayList<GrantedAuthority> auth = new ArrayList<GrantedAuthority>();
for(int i=0;i<authList.size();i++) {
auth.add(new SimpleGrantedAuthority(authList.get(i)));
}
this.authority=auth;
}
@Override
public String getPassword() {
// TODO Auto-generated method stub
return PASSWORD;
}
public void setPassword(String password) {
PASSWORD = password;
}
@Override
public String getUsername() {
// TODO Auto-generated method stub
return ID;
}
@Override
public boolean isAccountNonExpired() {
// TODO Auto-generated method stub
return true;
}
@Override
public boolean isAccountNonLocked() {
// TODO Auto-generated method stub
return true;
}
@Override
public boolean isCredentialsNonExpired() {
// TODO Auto-generated method stub
return true;
}
@Override
public boolean isEnabled() {
// TODO Auto-generated method stub
return ENABLED;
}
}
'Spring Series > Spring Framework' 카테고리의 다른 글
[Spring] 상속/인터페이스를 통한 확장 (0) | 2022.03.04 |
---|---|
[Spring] Spring Security(7) - 자동 로그인 기능 추가 (0) | 2021.02.09 |
[Spring] Spring Security(5) - 로그인 성공 커스터마이징 (0) | 2021.02.04 |
[Spring] Spring Security(4) - 로그인 실패 커스터마이징 (5) | 2021.02.02 |
[Spring] Spring Security(3) - DB연동 (0) | 2021.02.01 |
댓글