티스토리 뷰

Programming/Java

Spring Security에서 Remember Me 구현

파란크리스마스 2022. 2. 5. 15:33
728x90

출처

토큰관리용 테이블생성

CREATE TABLE persistent_logins (
  series VARCHAR(64) NOT NULL,
  username VARCHAR(64) NOT NULL,
  token VARCHAR(64) NOT NULL,
  last_used DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY USING BTREE (series)
);

Domain 객체

package com.bx.domain;

import java.util.Date;

public class PersistentLogins {

	// pk
	private String series;
	
	private String username;
	private String token;
	private Date last_used;
	
	public PersistentLogins() {
		
	}

	public PersistentLogins(String username, String series, String token, Date last_used) {
		this.username = username;
		this.series = series;
		this.token = token;
		this.last_used = last_used;
	}

	public void setSeries(String series) {
		this.series = series;
	}

	public String getSeries() {
		return this.series;
	}

	public void setUsername(String username) {
		this.username = username;
	}

	public String getUsername() {
		return this.username;
	}

	public void setToken(String token) {
		this.token = token;
	}

	public String getToken() {
		return this.token;
	}

	public void setLast_used(Date last_used) {
		this.last_used = last_used;
	}

	public Date getLast_used() {
		return this.last_used;
	}

}

Mapper 인터페이스

package com.bx.persistence;

import com.bx.domain.PersistentLogins;

public interface PersistentLoginsMapper {

	public PersistentLogins selectUserToken(String cookieSeries);

	public int insertUserToken(PersistentLogins persistentLogins);

	public int updateUserToken(PersistentLogins persistentLogins);

	public int deleteOneToken(String cookieValue);

}

Mapper xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.bx.persistence.PersistentLoginsMapper">

	<!-- selectUserToken -->
	<select id="selectUserToken" parameterType="java.lang.String" resultType="com.bx.domain.PersistentLogins">
		select *
		  from persistent_logins
		 where series = #{series}
	</select>

	<!-- updateUserToken -->
	<update id="updateUserToken" parameterType="com.bx.domain.PersistentLogins" statementType="PREPARED">
		update persistent_logins
		    <trim prefix="SET" suffixOverrides=",">
		      <if test="username != null">username = #{username, jdbcType=VARCHAR} ,</if>
		      <if test="token != null">token = #{token, jdbcType=VARCHAR} ,</if>
		      <if test="last_used != null">last_used = #{last_used, jdbcType=TIMESTAMP} ,</if>
		    </trim>
		 where series = #{series}
	</update>

	<!-- insertUserToken -->
	<insert id="insertUserToken" parameterType="com.bx.domain.PersistentLogins" statementType="PREPARED">
		insert into persistent_logins(
		    <trim suffixOverrides=",">
		      <if test="series != null">series ,</if>
		      <if test="username != null">username ,</if>
		      <if test="token != null">token ,</if>
		      <if test="last_used != null">last_used ,</if>
		    </trim>
		    ) values (
		    <trim suffixOverrides=",">
		      <if test="series != null">#{series, jdbcType=VARCHAR} ,</if>
		      <if test="username != null">#{username, jdbcType=VARCHAR} ,</if>
		      <if test="token != null">#{token, jdbcType=VARCHAR} ,</if>
		      <if test="last_used != null">#{last_used, jdbcType=TIMESTAMP} ,</if>
		    </trim>
		)
	</insert>

	<!-- deleteOneToken -->
	<delete id="deleteOneToken" parameterType="java.lang.String" statementType="PREPARED">
		delete from persistent_logins
		 where series = #{cookieValue}
	</delete>

</mapper>

AbstractRememberMeServices 구현

로그인시 토큰값 DB에 저장하고, 로그아웃시 토튼값 삭제 처리

package com.oxbridge.handler;

import java.security.SecureRandom;
import java.util.Date;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException;
import org.springframework.security.core.Authentication;
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.codec.Base64;
import org.springframework.security.web.authentication.rememberme.AbstractRememberMeServices;
import org.springframework.security.web.authentication.rememberme.CookieTheftException;
import org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationException;

import com.bx.domain.PersistentLogins;
import com.bx.persistence.PersistentLoginsMapper;

public class UserLoginRememberMeService extends AbstractRememberMeServices {
	
	private static final Logger logger = LoggerFactory.getLogger(UserLoginRememberMeService.class);
	
	/* token값 신규 생성을 위한 랜덤 넘버 생성 객체 */
	SecureRandom random;
	
	@Autowired
	private PersistentLoginsMapper mapper;

	/* 생성자 */
	public UserLoginRememberMeService(String key, UserDetailsService userDetailsService) {
		super(key, userDetailsService);
		random = new SecureRandom();
	}

	@Override
	/* 첫 로그인 시 쿠키 발행 및 토큰정보 DB 업데이트 */
	protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
		
		// 사용자 쿠키 겁색
		String cookieValue = super.extractRememberMeCookie(request);

		// 사용자 쿠키가 존재할 경우 DB에서 해당 데이터를 삭제함
		// 2차 인증하지 않으면 자동로그인 되지 못하는 쿠키가 남아있을 수 있음
		if (cookieValue != null) {
			
			// series 값 기준으로 해당 토큰을 DB에서 삭제
			mapper.deleteOneToken(decodeCookie(cookieValue)[0]);
		}

		// 새로운 series, token 값 생성
		String username = successfulAuthentication.getName();
		String newSeriesValue = generateTokenValue();
		String newTokenValue = generateTokenValue();

		// 쿠키 발급 및 DB insert
		try {
			PersistentLogins rememberMeVO = new PersistentLogins(username, newSeriesValue, newTokenValue, new Date());

			// DB insert
			mapper.insertUserToken(rememberMeVO);

			// 쿠키 발행
			String[] rawCookieValues = new String[] { newSeriesValue, newTokenValue };
			super.setCookie(rawCookieValues, getTokenValiditySeconds(), request, response);

		} catch (DataAccessException e) {
			e.printStackTrace();
		}

		/*
		// 2차 인증을 위한 메일 발송
		String ip = request.getHeader("X-Forwarded-For");
		if (ip == null) {
			ip = request.getRemoteAddr();
		}
		String userAgent = request.getHeader("user-agent");

		boolean isSended = emailSender.sendSecondCertifyingEmail(username, newSeriesValue, newTokenValue, ip, userAgent);
		if (!isSended) {
			request.getSession().setAttribute("rememberMeMsg", "메일 전송에 실패했습니다. 등록된 메일 주소를 확인해주세요.");
		}
		*/
	}

	@Override
	/* 자동 로그인 로직 - 쿠키 유효성 검증 및 사용자 정보 객체 리턴 */
	protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) throws RememberMeAuthenticationException, UsernameNotFoundException {
		
		// 쿠키 : series, token
		// 포함된 값이 2개가 아닌 경우
		if (cookieTokens.length != 2) {
			throw new RememberMeAuthenticationException("잘못된 쿠키");
		}

		String cookieSeries = cookieTokens[0];
		String cookieToken = cookieTokens[1];

		// DB 토큰 정보 확인
		PersistentLogins rememberMeVO = mapper.selectUserToken(cookieSeries);

		// DB에 정보가 없을 경우
		if (rememberMeVO == null) {
			throw new RememberMeAuthenticationException("존재하지 않는 series");
		}

		// DB에 series는 있는데 Token 값이 같지 않을 경우
		if (!cookieToken.equals(rememberMeVO.getToken())) {

			// DB에서 해당 데이터 삭제
			mapper.deleteOneToken(cookieSeries);
			throw new CookieTheftException("변조된 쿠키 발견");
		}
		
		/*
		// DB에 series는 있는데 certified가 null인 경우 (메일 인증되지 않은 쿠키)
		if (rememberMeVO.getCertified() == null) {
			throw new RememberMeAuthenticationException("메일 인증되지 않은 쿠키");
		}
		*/

		// 유효기간 검증
		if (rememberMeVO.getLast_used().getTime() + getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
			// DB에서 해당 데이터 삭제
			mapper.deleteOneToken(cookieSeries);
			throw new RememberMeAuthenticationException("유효기간 만료 쿠키");
		}

		// 신규 token 값으로 업데이트
		String newToken = generateTokenValue();
		rememberMeVO.setToken(newToken);
		rememberMeVO.setLast_used(new Date());

		try {
			// DB에 새로운 token 값 업데이트
			mapper.updateUserToken(rememberMeVO);

			// 변경된 token 값으로 새로운 쿠키 발행
			String[] rawCookieValues = new String[] { cookieSeries, newToken };
			super.setCookie(rawCookieValues, getTokenValiditySeconds(), request, response);

		} catch (DataAccessException e) {
			e.printStackTrace();
			throw new RememberMeAuthenticationException("새로운 token DB 업데이트 실패");
		}
		//

		// 모두 인증됐으면 사용자 정보 객체 찾아서 반환 (예외 발생 시 super에서 처리해줌)
		return getUserDetailsService().loadUserByUsername(rememberMeVO.getUsername());
	}

	@Override
	/* 로그아웃 시 쿠키/DB 정보 삭제 */
	public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
		
		logger.debug("logout-1");

		// 웹에서 모든기기 로그아웃 요청한 경우
		if (request.getParameter("logoutAllWeb") != null) {
			/*
			String username = (String) request.getParameter("logoutAllWeb");
			
			// 권한이 있을 경우에만 로그아웃 작동 (다른 사람이 URI로 직접 요청하는 경우를 막기 위함)
			if (authentication != null && authentication.getName().equals(username)) {
				mapper.deleteAllUserToken(username);
			}
			*/

			// 인증 메일에서 모든 기기 로그아웃 요청한 경우
		} else if (request.getParameter("logoutAllEmail") != null) {

			/*
			// 디코딩 후 사용자 토큰 데이터 모두 삭제
			String encodedUsername = (String) request.getParameter("logoutAllEmail");
			String[] username = emailSender.decodeValues(encodedUsername);
			if (username != null) {
				mapper.deleteAllUserToken(username[0]);
			}
			*/

			// 현재 기기 로그아웃 요청일 경우
			// series 기준으로 DB의 데이터 삭제
		} else {

			// DB token 삭제 (username이 아닌 해당 series의 정보만 삭제)
			String decodedCookieValue = super.extractRememberMeCookie(request);
			if (decodedCookieValue != null) {
				String[] cookieTokens = super.decodeCookie(decodedCookieValue);
				if (cookieTokens != null && cookieTokens.length == 2) {
					mapper.deleteOneToken(cookieTokens[0]);
				}
			}
		}

		// 쿠키 삭제
		super.logout(request, response, authentication);
	}

	/* Series, Token 랜덤값으로 생성후 인코딩 */
	private String generateTokenValue() {
		byte[] newToken = new byte[16];
		random.nextBytes(newToken);
		return new String(Base64.encode(newToken));
	}
}

Spring security 설정

	<bean id="archimAuthDetSource" class="com.bx.handler.ArchimAuthDetSource"/>
	
	<bean id="rememberMeFilter" class="org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationFilter">
		<property name="rememberMeServices" ref="rememberMeServices"/>
		<property name="authenticationManager" ref="authenticationManager" />
	</bean>
	
	<bean id="rememberMeServices" class="com.bx.handler.UserLoginRememberMeService">
		<constructor-arg value="springRocks"/>
		<constructor-arg ref="tbUserService"/>
	</bean>
	
	<bean id="rememberMeAuthenticationProvider" class="org.springframework.security.authentication.RememberMeAuthenticationProvider">
		<property name="key" value="springRocks"/>
	</bean>
	
	<bean id="bxAuthenticationProvider" class="com.bx.handler.BXAuthenticationProvider"/>
	
	<security:authentication-manager alias="authenticationManager">
		<security:authentication-provider ref="bxAuthenticationProvider" /> <!-- 권한 설정할 커스텀 Bean -->
		<security:authentication-provider ref="rememberMeAuthenticationProvider" />
		<security:authentication-provider user-service-ref="tbUserService"/>
	</security:authentication-manager>

로그인 페이지

	<div class="login-content">
		<form action="<c:url value='/j_spring_security_check' />" method='POST' class="margin-bottom-0">
			<input type="hidden" id="user_type" name="user_type" value="01">
			<div class="form-group m-b-20">
				<input type="text" name='j_username' class="form-control form-control-lg inverse-mode" placeholder="아이디" required />
			</div>
			<div class="form-group m-b-20">
				<input type="password" name='j_password' class="form-control form-control-lg inverse-mode" placeholder="암호" required />
			</div>
			<div class="checkbox checkbox-css m-b-20">
				<input type="checkbox" id="remember_checkbox" name="_spring_security_remember_me"/> 
				<label for="remember_checkbox">Remember Me</label>
			</div>
			<div class="login-buttons">
				<button type="submit" class="btn btn-success btn-block btn-lg" style="font-size:large;">로그인</button>
			</div>
			<!--  csrf 공격 방어를 위해 동적 생성 -->
			<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token }" />
		</form>
	</div>
댓글
300x250
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
«   2024/04   »
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
글 보관함