▸Spring Security

스프링 Security_Remember-me 커스터마이징 [2/3]

코데방 2020. 4. 22.
728x90
- Develop OS : Windows10 Ent, 64bit
- WEB/WAS Server : Tomcat v9.0
- DBMS : MySQL 5.7.29 for Linux (Docker)
- Language : JAVA 1.8 (JDK 1.8)
- Framwork : Spring 3.1.1 Release
- Build Tool : Maven 3.6.3
- ORM : Mybatis 3.2.8

 

이전 글에서는 컨텍스트(Bean) 설정을 통해 디폴트 Remember-me가 가진 정보를 간단하게 커스터마이징 했었습니다. 하지만 이 경우 로직 자체는 여전히 디폴트로 사용할 수밖에 없기 때문에 이번에는 새로운 로직을 적용하기 위한 커스터마이징을 하도록 하겠습니다.

 

먼저 디폴트 Remember-me를 구성하는 클래스들의 구조는 아래와 같습니다.

 

 

 

이제까지의 커스터마이징과 동일하게 추상 클래스 또는 하위의 실제 구현체 클래스를 대체할 수 있는 클래스를 하나 만들어 주입해주면 됩니다. 추상 클래스에서는 변하지 않는 로직의 뼈대를 잡고 있고, 하위 구현체에서는 커스터마이징 할 수 있는 부분에 대한 메소드를 오버라이딩해 구현하고 있습니다. 

 

따라서 추상 클래스 레벨부터 커스터마이징을 할 것인지, 그렇지 않으면 구현체 레벨에서 커스터마이징을 할 것인지 결정해줘야 합니다.

 

 

 

판단은 실제 로직을 보고 하면 됩니다. 저 같은 경우는 아래 내용에 대해 커스터마이징을 진행할 예정인데 굳이 추상 클래스에 정의된 로직을 건들이지 않아도 돼서 추상 클래스를 상속받은 새로운 클래스를 하나 만들었습니다. 추상 클래스 레벨에서도 새로 만들어봤는데 크게 어렵지 않은 로직들이라 코드를 참조하면서 작성하면 금방 작성할 수 있습니다. 또 추상 클래스에는 인코딩, 디코딩 등의 메소드가 모두 작성되어 있어 그대로 가져다쓰면 편리하다는 장점도 있습니다.

 

 

* 커스터마이징 내용

 

1. 기기에 Remember-me 첫 등록 시에 메일 인증을 받도록 함

  •   메일 인증을 받지 않은 쿠키로는 자동 로그인을 할 수 없도록 함
  •   메일에서 '인증하기'를 누르면 Remember-me 체크하고 로그인했던 기기에 자동 로그인 적용
  •   메일에서 '인증정보 삭제하기'를 누르면 사용자의 모든 자동 로그인 정보(쿠키, 토큰)를 무효화시킴
  •   RememberMeService 구현체 커스터마이징
  •   신규 커스텀 필터(Custom Filter) 작성 및 적용

2. 옵션별 다른 로그아웃 로직 적용

  •   일반적인 로그아웃의 경우 해당 기기의 자동 로그인만 해제함
  •   모든기기 로그아웃의 경우 사용자의 모든 자동 로그인 정보(쿠키, 토큰)을 무효화시킴
  •   RememberMeService 구현체 커스터마이징

3. 모든 작업은 Spring Security 내에서 해결하도록 함

  •   MVC 패턴과는 별도로 동작하는 것을 원칙으로 함

 

전체코드는 아래 "더보기"를 클릭하시면 됩니다.

더보기

* 커스터마이징 RememberMeService 구현체 클래스

package hs.spring.hsweb.service.user;

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

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

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 hs.spring.hsweb.mapper.user.UserRememberMeMapper;
import hs.spring.hsweb.mapper.vo.user.UserRememberMeVO;

public class UserLoginRememberMeService extends AbstractRememberMeServices {

	@Autowired
	/* DB 작업을 위한 mapper */
	UserRememberMeMapper mapper;

	@Autowired
	/* 인증 메일 발송 객체 */
	UserLoginRememberMeCertifyingEmail emailSender;

	/* token값 신규 생성을 위한 랜덤 넘버 생성 객체 */
	SecureRandom random;

	/* 생성자 */
	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 {
			UserRememberMeVO rememberMeVO = new UserRememberMeVO(username, newSeriesValue,
					newTokenValue, new Date(), null);

			// 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 토큰 정보 확인
		UserRememberMeVO 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.getLastUsed().getTime()
				+ getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {

			// DB에서 해당 데이터 삭제
			mapper.deleteOneToken(cookieSeries);
			throw new RememberMeAuthenticationException("유효기간 만료 쿠키");
		}

		// 신규 token 값으로 업데이트
		String newToken = generateTokenValue();
		rememberMeVO.setToken(newToken);
		rememberMeVO.setLastUsed(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) {

		// 웹에서 모든기기 로그아웃 요청한 경우
		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));
	}
}

 

 


 

 

1. AbstractRememberMeServices 추상 클래스를 상속

 

Remember-me 서비스의 실제 구현체가 될 클래스를 하나 만들어줍니다. 필수로 오버라이딩해야하는 추상 메소드 2개가 있습니다. 저는 로그아웃도 커스터마이징 해야하므로 하나를 더 추가해서 3개의 메소드를 작성하도록 하겠습니다.

 

- onLoginSuccess (필수, 첫 로그인 시 자동 로그인 관련 처리)

- processAutoLoginCookie (필수, 자동 로그인 처리)

- logout(선택, 로그아웃 시 자동 로그인 관련 처리)

package hs.spring.hsweb.config.annotation;

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

import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.rememberme.AbstractRememberMeServices;
import org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationException;

public class UserLoginRememberMeService extends AbstractRememberMeServices
{

	@Override
	/* 첫 로그인 시 쿠키 발행 및 토큰정보 DB 업데이트 */
	protected void onLoginSuccess(HttpServletRequest request,
			HttpServletResponse response, Authentication successfulAuthentication) {
		// TODO Auto-generated method stub
		
	}

	@Override
	/* 자동 로그인 로직 - 쿠키 유효성 검증 및 사용자 정보 객체 리턴 */
	protected UserDetails processAutoLoginCookie(String[] cookieTokens,
			HttpServletRequest request, HttpServletResponse response)
			throws RememberMeAuthenticationException, UsernameNotFoundException {
		// TODO Auto-generated method stub
		return null;
	}
    
	@Override
	/* 로그아웃 시 자동 로그인 관련 정보 처리 */
	public void logout(HttpServletRequest request, HttpServletResponse response,
			Authentication authentication) {
	}
}

 

 


 

 

2. 생성자 적용

 

상속 받은 추상 클래스의 생성자를 이어줍니다. 그리고 토큰 값을 생성할 랜덤 클래스도 같이 만들어줍니다. 그냥 자바의 Random 클래스를 이용해도 되고 스프링 Security의 SecureRandom 클래스를 사용해도 됩니다.

 

첫 로그인에서 자동 로그인 클릭 시 인증 메일을 보내는 클래스는 따로 만들어줬는데, 이 객체 또한 필드멤버로 만들어 자동 주입받습니다. 그리고 DB 또한 새로 만들어 사용할 것이므로 mapper 객체를 주입해줍니다. 저는 Mybatis를 사용하는데 사용하는 방식에 맞춰주면 됩니다.

 

public class UserLoginRememberMeService extends AbstractRememberMeServices {

	@Autowired
	/* DB 작업을 위한 mapper */
	UserRememberMeMapper mapper;

	@Autowired
	/* 인증 메일 발송 객체 */
	UserLoginRememberMeCertifyingEmail emailSender;

	/* token값 신규 생성을 위한 랜덤 넘버 생성 객체 */
	SecureRandom random;

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

 

 

 


 

 

 

3. DB 테이블 생성

 

기본적인 Remember-me 테이블에 메일 인증 여부를 확인할 컬럼을 하나 더 생성해줬습니다. 

 

    create table user_remember_me (username varchar(64) not null,
                                    series varchar(64) primary key,
                                    token varchar(64) not null,
                                    last_used timestamp not null,
                                    cerified char(4));

 

 


 

 

4. onLoginSuccess() 메소드 오버라이딩

  - 로그인 성공 시 쿠키 생성 및 토큰 정보 DB 업데이트

 

로그인을 처리하는 UsernamePasswordAuthenticationFilter 에서 호출하는 autoLogin() 메소드에서 다시 호출하는 메소드입니다. 로그인이 정상적으로 될 경우 권한 객체를 같이 보내주기 때문에 어떤 사용자가 로그인했는지 알 수 있습니다. 추상 클래스에서는 사용자가 자동로그인에 체크를 해서 지정한 parameter가 있는지 확인한 뒤 있으면 이 메소드를 호출해 처리합니다. 

 

새로운 series, token 값을 발급해서 DB에 업데이트해주고 암호화된 쿠키를 만들어 발급해주면 됩니다. 쿠키와 관련된 로직은 추상 클래스에 있는 메소드를 그대로 사용하면 됩니다. 작성하기 전에 추상 클래스에 어떤 메소드가 있는지 로직을 쭉 훑어보면 좋습니다. 그대로 사용한 메소드는 구분하기 좋게 super를 붙여놨습니다.

 

처음 DB에 토큰 정보를 insert 할 때는 certified 컬럼값을 null로 줬습니다. 나중에 자동 로그인 쿠키 체크를 할 때 이부분이 null이면 유효하지 않은 쿠키로 판단해 자동 로그인 시켜주지 않도록 합니다. 그리고 인증 메일에서 '인증하기'를 누르면 해당 series의 certified 컬럼값을 "true"로 업데이트 시켜주면 됩니다.

 

	@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 {
			UserRememberMeVO rememberMeVO = new UserRememberMeVO(username, newSeriesValue,
					newTokenValue, new Date(), null);

			// 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",
					"메일 전송에 실패했습니다. 등록된 메일 주소를 확인해주세요.");
		}
	}
    
	/* Series, Token 랜덤값으로 생성후 인코딩 */
	private String generateTokenValue() {
		byte[] newToken = new byte[16];
		random.nextBytes(newToken);
		return new String(Base64.encode(newToken));
	}

 

 

어려운 부분은 없으니 자세한 설명은 생략하도록 하겠습니다. 인증 메일을 보내는 방법은 아래 링크를 참조하시면 됩니다. 

 

[Spring MVC/- 기본 문법] - SMTP 서버를 이용한 스프링 메일 발송 (네이버 메일)

 

 

저는 보안을 위해 메일에 실제 정보(아이디, series, token값)를 노출하지 않도록 모두 암호화해서 발송했습니다. 나중에 이를 처리하는 클래스에서는 파라미터값을 디코딩해서 사용하면 됩니다. 디코딩 로직 또한 메일을 보내는 클래스 안에 있고, 디코딩이 필요한 클래스에서는 메일 클래스의 Bean 객체를 주입받아 메소드를 사용하도록 했습니다. 의존 관계가 형성돼서 고민을 좀 많이 했지만 어차피 메일 인증과 한 세트가 되는 클래스들은 의존 관계가 되어도 무방하다는 판단입니다.

 

아래 "더보기"를 누르면 메일 발송 클래스 코드가 있습니다. 메일 내용은 일단 VO 객체를 하나 만들어서 담아 사용했는데 나중에 사용하기 좋게 프로퍼티 파일을 사용하도록 고칠 예정입니다.

 

 

더보기

* 이메일 내용을 완성할 VO 객체

package hs.spring.hsweb.mapper.vo.user;

/* Remember-me 인증 메일 내용 */
public class UserRememberMeEmailVO {

	private String ip;
	private String userAgent;
	private String certifyingLink; // 인증 링크 URI
	private String rollbackLink; // 롤백 링크 URI
	private String subject; // 메일 제목
	private String content; // 메일 본문

	public UserRememberMeEmailVO(String ip, String hostname, String certifyingLink,
			String rollbackLink) {

		this.ip = ip;
		this.userAgent = hostname;
		this.certifyingLink = certifyingLink;
		this.rollbackLink = rollbackLink;

		// 제목 삽입
		setSubject();

		// 내용 완성
		setContent();
	}

	// 제목 완성 (추후 프로퍼티로 대체)
	private void setSubject() {

		subject = "HSWEB 자동 로그인 인증 메일입니다.";

	}

	public String getSubject() {
		return subject;
	}

	// 내용 완성 (추후 프로퍼티로 대체)
	private void setContent() {

		content = "<p>IP :&nbsp;" + ip + "</p><p>Agent :&nbsp;" + userAgent
				+ "</p><p><br></p><p>" + "위 주소에서 로그인을 완료하였고, "
				+ "자동 로그인을 요청했습니다.</p><p><br></p><p>인증하기를 원하시면 아래 \"인증하기\"를 클릭해주세요.</p>"
				+ "<p><br></p><p><a href=\"" + certifyingLink
				+ "\" target=\"_blank\" style=\"cursor: "
				+ "pointer; white-space: pre;\" rel=\"noreferrer noopener\">인증하기</a><span></span>"
				+ "</p><p><br></p><p><br></p><p>만약 잘못된 요청이라면 아래 \"인증 정보 삭제하기\"를 클릭해주세요. "
				+ "모든 기기의 자동 로그인이 해제됩니다.</p><p><br></p><p><a href=\"" + rollbackLink
				+ "\" target=\"_blank\" style=\"cursor: "
				+ "pointer;\" rel=\"noreferrer noopener\">인증 정보 삭제하기</a></p><p><br></p><p><br>"
				+ "</p>\r\n" + "\r\n" + "";
	}

	public String getContent() {
		return content;
	}
}

 

 

* 이메일 발송을 담당하는 클래스

package hs.spring.hsweb.service.user;

import javax.mail.internet.MimeMessage;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.security.crypto.codec.Base64;
import org.springframework.stereotype.Service;

import hs.spring.hsweb.mapper.user.UserMapper;
import hs.spring.hsweb.mapper.vo.user.UserInfoVO;
import hs.spring.hsweb.mapper.vo.user.UserRememberMeEmailVO;

@Service
/* Remember-me 2차 인증 메일 전송 */
public class UserLoginRememberMeCertifyingEmail {

	@Autowired
	/* 메일 발송 객체 */
	JavaMailSenderImpl mailSender;

	@Autowired
	/* 사용자 Email 주소를 DB에서 가져오기 위한 mapper */
	UserMapper mapper;

	/* 사용자 정보에 등록된 Email로 자동 로그인 인증 메일을 발송 */
	public boolean sendSecondCertifyingEmail(String username, String series, String token, String ip,
			String userAgent) {

		// DB에서 사용자 정보 가져옴
		UserInfoVO userInfo = mapper.selectUserInfoOne(username);
		if (userInfo == null) {
			return false;
		}

		// 이메일 주소가 비어있을 경우
		if (userInfo.getUserEmail() == null || userInfo.getUserEmail().equals("")) {
			return false;
		}

		// username 인코딩 (타인이 URI에 ID를 넣어서 직접 롤백을 시도하는 것을 막기 위함)
		String encodedUsername = encodeValues(new String[] { username });

		// series, token 정보 인코딩 (노출 방지)
		String encodedSeries = encodeValues(new String[] { series, token });

		// 메일 정보 셋팅
		String from = "codevang@naver.com";
		String to = userInfo.getUserEmail();
		String certifyingLink = "http://localhost//rememberMeCertifying?key="
				+ encodedSeries;
		String rollbackLink = "http://localhost/logoutAsk?logoutAllEmail="
				+ encodedUsername;

		// 메일 내용 VO 객체
		UserRememberMeEmailVO emailVO = new UserRememberMeEmailVO(ip, userAgent,
				certifyingLink, rollbackLink);

		String subject = emailVO.getSubject();
		String content = emailVO.getContent();

		try {
			MimeMessage message = mailSender.createMimeMessage();
			MimeMessageHelper messageHelper = new MimeMessageHelper(message, "UTF-8");

			messageHelper.setFrom(from);
			messageHelper.setTo(to);
			messageHelper.setSubject(subject);
			messageHelper.setText(content, true);

			mailSender.send(message);

			// 정상적으로 메일 발송됐으면 true 리턴
			return true;

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

		// 여기까지 왔다면 예외 발생한 경우
		return false;
	}

	/* username(ID) 인코딩 */
	public String encodeValues(String[] values) {

		// 값들을 붙여서 하나로 만들어 줌
		StringBuilder sb = new StringBuilder();
		for (int i = 0; i < values.length; i++) {
			sb.append(values[i]);
			if (i < values.length - 1) {
				sb.append(":");
			}
		}

		// 인코딩
		String value = sb.toString();
		sb = new StringBuilder(new String(Base64.encode(value.getBytes())));

		// 뒤에 붙은 '='을 빼줌
		while (sb.charAt(sb.length() - 1) == '=') {
			sb.deleteCharAt(sb.length() - 1);
		}

		// 인코딩된 String 객체 리턴
		return sb.toString();
	}

	/* username 디코딩 */
	public String[] decodeValues(String encodedValue) {

		// 인코딩할 때 빼준 '='을 다시 붙여줌
		for (int j = 0; j < encodedValue.length() % 4; j++) {
			encodedValue = encodedValue + "=";
		}

		// 인코딩 타입 유효성 확인
		if (!Base64.isBase64(encodedValue.getBytes())) {
			return null;
		}

		// 디코딩
		String decodedValue = new String(Base64.decode(encodedValue.getBytes()));

		// 각 값으로 분리
		String[] values = decodedValue.split(":");

		// 디코딩된 문자열 리턴
		return values;
	}
}

 

 

 


 

 

5. processAutoLoginCookie() 메소드 오버라이딩

  - 접속 시 쿠키 체크 후 자동 로그인 수행

 

접속할 때마다 RememberMeAuthenticationFilter가 호출하는 autoLogin()에서 다시 호출하는 메소드입니다. 추상 클래스에서는 지정한 쿠키 이름과 일치하는 쿠키가 있는지, 내용이 비어있지 않은지를 확인한 뒤 해당 메소드를 호출해 자동 로그인을 처리합니다.

 

작성할 때 주의할 점은 자동 로그인에 실패했을 경우 null값을 반환하면 안되고 예외를 throw 해야한다는 것입니다. 아래 호출 메소드 로직을 보면 반환받은 객체를 가지고 check 메소드를 실행하기 때문에 만약 null값을 반환하게 되면 null 포인터 예외가 발생합니다. 따라서 추상 클래스에서 처리하는 예외에 맞도록 예외를 던져줘야 합니다.

 

* AbstractRememberMeServices 클래스의 autoLogin() 메소드 일부

  try {
            String[] cookieTokens = decodeCookie(rememberMeCookie);
            user = processAutoLoginCookie(cookieTokens, request, response);
            userDetailsChecker.check(user);

            logger.debug("Remember-me cookie accepted");

            return createSuccessfulAuthentication(request, user);
        } catch (CookieTheftException cte) {
            cancelCookie(request, response);
            throw cte;
        } catch (UsernameNotFoundException noUser) {
            logger.debug("Remember-me login was valid but corresponding user not found.", noUser);
        } catch (InvalidCookieException invalidCookie) {
            logger.debug("Invalid remember-me cookie: " + invalidCookie.getMessage());
        } catch (AccountStatusException statusInvalid) {
            logger.debug("Invalid UserDetails: " + statusInvalid.getMessage());
        } catch (RememberMeAuthenticationException e) {
            logger.debug(e.getMessage());
        }

 

 

 

로직은 간단합니다. 쿠키 값을 디코딩해서 series, token 값을 얻은 뒤 DB 정보와 일치하는지 확인한 후 새로운 값으로 업데이트 시켜주면 됩니다. 그리고 정상적으로 모두 진행되면 해당 사용자의 username으로 사용자 인증 정보인 UserDetails 객체를 만들어 리턴시켜주면 됩니다.

 

저는 이 과정 중간에 series, token 값이 일치하더라도 메일 인증 여부인 certified 컬럼값이 null일 경우 자동로그인 시켜주지 않는 로직을 추가했습니다. 

	@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 토큰 정보 확인
		UserRememberMeVO 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.getLastUsed().getTime()
				+ getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {

			// DB에서 해당 데이터 삭제
			mapper.deleteOneToken(cookieSeries);
			throw new RememberMeAuthenticationException("유효기간 만료 쿠키");
		}

		// 신규 token 값으로 업데이트
		String newToken = generateTokenValue();
		rememberMeVO.setToken(newToken);
		rememberMeVO.setLastUsed(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());
	}

 

 

 


 

 

 

6. logout() 메소드 오버라이딩

 

로그아웃을 담당하는 LogoutFilter에서 호출하는 메소드입니다. 자동 로그인에 관련된 쿠키, DB 정보를 지워주는 역할을 합니다.

 

오버라이딩은 선택사항이고, 추상 클래스에서는 쿠키만 삭제합니다. 커스터마이징 이전 구현체에서는 쿠키 삭제에 더해 인증DB에서 username 기준으로 모든 token 데이터를 삭제합니다. 모든 기기의 자동 로그인이 한번에 해제되는 것이죠.

 

저는 모든기기의 자동로그인을 해제하는 것과 현재기기의 자동로그인을 해제하는 것 두 가지 로직으로 구분했습니다. 또 인증메일에서는 '인증 정보 삭제하기'를 클릭할 경우 모든기기의 자동로그인을 해제하도록 했는데, 이 때 아이디 정보를 암호화해두었기 때문에 디코딩을 해주는 작업이 별도로 필요합니다. 이를 위해 각자 요청하는 파라미터를 나눠 처리하도록 했습니다.

 

로직은 간단하니 코드를 보시면 간단히 이해할 수 있습니다. 디코딩 메소드는 위에 있는 인증 메일 클래스에서 가져다 사용합니다. 

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

		// 웹에서 모든기기 로그아웃 요청한 경우
		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);
	}

 

 


 

 

7. 컨텍스트 설정 파일 변경

 

추상 클래스를 상속받았을 경우 생성자가 필수이므로 컨텍스트에서 Bean을 등록해줍니다. 이전글의 간단한 버전의 커스터마이징과 동일하고, 구현체만 바꿔주면 됩니다. 이전글에서 설명했듯이 위아래 key값은 무조건 동일하게 맞춰줘야 합니다. 생성자 및 프로퍼티에 관한 내용은 이전글을 참조하시면 됩니다.

 

<!-- 자동 로그인(커스텀) -->
<s:remember-me services-ref="userLoginRememberMeService" key="codevang"
	authentication-success-handler-ref="userLoginSuccessHandler" />
            
<!-- Custom Remember-me Bean -->
<bean id="userLoginRememberMeService"
	class="hs.spring.hsweb.service.user.UserLoginRememberMeService">
	<constructor-arg name="key" value="codevang" />
	<constructor-arg name="userDetailsService" ref="userLoginDetailsService" />
	<property name="cookieName" value="HSWEB_U_REMEMBER" />
	<property name="parameter" value="remember-me" />
	<property name="tokenValiditySeconds" value="60000" />
</bean>

 

 


 

 

이제 인증 메일에서 '인증하기'를 눌렀을 때 DB 테이블의 cerified 컬럼값을 "true"로 업데이트해주는 것만 남았습니다. 다음글에 이어서 정리하도록 하겠습니다.

 

[Spring Security] - 스프링 Security_Remember-me 커스터마이징(커스텀 필터) [3/3]

 

 

728x90

댓글

💲 추천 글