2021.10.23 - [IT 기술/권한 인증&인가] - [Spring Security] Authentication 라이브러리 구현
6. 로그인 기능 구현
spring-security의 DaoAuthenticationProvider를 커스텀하였다. (CustomAuthenticationProvider)
- CustomAuthenticationProvider에서는 DB에서 사용자 정보를 가져오기 위한 CustomUserDetailsService를 사용한다.
- 로그인 인증(비밀번호 체크, 기타 계정 상태)을 하기 위한 PreAccountStatusUserDetailsChecker, PostAccountStatusUserDetailsChecker 를 사용한다.
6-1. CustomUserDetailsService.java
CustomUserDetailsService는 spring-security의 UserDetailsService 인터페이스를 따른다.
spring-security의 '로그인 인증'관련 필터는 세션-쿠키 기반 폼로그인 요청이 들어오면 UserDetailsService의 loadUserByUsername 메소드를 통해 DB에서 정보를 읽어와 UserDetails를 생성한다.
/**
* HomeService
*
* UserDetailsService인터페이스를 구현
*
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService
{
@Value("${security.login.fail.imsi-lock.lock-minute}")
private Integer imsiLockLockMinute;
@Value("${security.credentials-expired.month}")
private Integer credentialsExpiredMonth;
@Value("${security.credentials-expired.date}")
private Integer credentialsExpiredDate;
@Value("${security.credentials-expired.use}")
private boolean credentialsExpiredUse;
private final AuthService authService;
@Autowired
private AuthUserService authUserService;
/*
* 로그인
*/
@Override
public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException
{
User user = null;
ArrayList<GrantedAuthority> authlist = new ArrayList<GrantedAuthority>();
try
{
// TB_USER 정보
user = authUserService.getUserByLoginId(userId);
// 권한그룹
user.setUsrgrpList(authService.getUserGroupByUserId(user.getUserSid()));
// 메뉴
user.setMenuList(authService.getMenuListByUserSid(user.getUserSid()));
// GrantedAuthority stting ======================================
for (Usrgrp ugv : user.getUsrgrpList()) {
AuthGroup authGroup = new AuthGroup(ugv.getUsrgrpSid(), ugv.getUsrgrpNm());
// 역할 리스트
List<AuthRole> hasRoleList = new ArrayList<AuthRole>();
if(!ObjectUtils.isEmpty(ugv.getRoleList()))
{
for (Role rolw : ugv.getRoleList()) {
hasRoleList.add(new AuthRole(rolw.getRoleId(), rolw.getRoleId()));
}
}
authGroup.setAuthRoleList(hasRoleList);
this.setChildAuthGroup(authGroup, ugv.getChildUsrgrpList());
authlist.add(new UserGrantedAuthority(authGroup));
}
}
catch (UsernameNotFoundException e) {
throw e;
}
// 계정 상태 여부 ===============================================
boolean enabled = true; // 활성화
boolean credentialsNonExpired = true; // 비번만료x
boolean accountNonLocked = true; // 계정잠김x
ACNT_STTS_CD acntStts = user.getAcntSttsCd(); // 계정상태
LOGIN_PRVNT_STTS_CD loginPrvntStts = user.getLoginPrvntSttsCd(); // 로그인 방지 상태
SVC_STTS_CD svcStts = user.getSvcSttsCd(); // 서비스 상태
if(acntStts == ACNT_STTS_CD.Active && !ObjectUtils.isEmpty(loginPrvntStts))
{
switch (loginPrvntStts) {
case AccountDisabledByBalckList:
enabled = false;
break;
case LockedByPassword:
accountNonLocked = !this.checkUserLock(user.getAcntLockDt());
break;
default:
break;
}
}
// 비번 만료
if(credentialsExpiredUse)
{
credentialsNonExpired = this.checkCredentialsNonExpried(user.getPwdUpdDt());
}
// UserDetails 세팅 =============================================
// id, pwd, 활성화, 계정만료x, 비밀번호만료x, 계정잠김x
CustomUserDetails userDetails = new CustomUserDetails(user.getLoginId(), user.getPwd(),authlist
, enabled, true, credentialsNonExpired, accountNonLocked);
userDetails.setUser(user);
if(!ObjectUtils.isEmpty(acntStts))
{
userDetails.setAcntStts(acntStts);
}
if(!ObjectUtils.isEmpty(loginPrvntStts))
{
userDetails.setLoginPrvntStts(loginPrvntStts);
}
if(!ObjectUtils.isEmpty(svcStts))
{
userDetails.setSvcStts(svcStts);
}
return userDetails;
}
public void setChildAuthGroup(AuthGroup authGroup, List<Usrgrp> childUserGroupList)
{
List<AuthGroup> cAuthGroupList = new ArrayList<AuthGroup>();
if(childUserGroupList != null)
{
for (Usrgrp ugv : childUserGroupList) {
AuthGroup cAuthGroup = new AuthGroup(ugv.getUsrgrpSid(), ugv.getUsrgrpNm());
// 역할 리스트
List<AuthRole> hasRoleList = new ArrayList<AuthRole>();
if(!ObjectUtils.isEmpty(ugv.getRoleList()))
{
for (Role rolw : ugv.getRoleList()) {
hasRoleList.add(new AuthRole(rolw.getRoleId(), rolw.getRoleId()));
}
}
cAuthGroup.setAuthRoleList(hasRoleList);
this.setChildAuthGroup(cAuthGroup, ugv.getChildUsrgrpList());
cAuthGroupList.add(cAuthGroup);
}
}
if(cAuthGroupList.size() > 0)
{
authGroup.setChlidAuthGroupList(cAuthGroupList);
}
}
public boolean checkUserLock(Date lockDt)
{
// true : 잠김
if(lockDt == null || ObjectUtils.isEmpty(imsiLockLockMinute))
{
return true;
}
Date unLockedDt = DateUtil.minuteAdd(lockDt, imsiLockLockMinute);
return unLockedDt.after(new Date());
}
public boolean checkCredentialsNonExpried(Date pwdUpdDt)
{
// expiredDt : 비밀번호 만료일
// tre : 비밀번호 만료아님.
Date expiredDt = DateUtil.dayAdd(
DateUtil.monthAdd(pwdUpdDt, (ObjectUtils.isEmpty(credentialsExpiredMonth) ? 0 : credentialsExpiredMonth))
, (ObjectUtils.isEmpty(credentialsExpiredDate) ? 0 : credentialsExpiredDate));
return expiredDt.after(new Date());
}
}
- 로그인 시도한 id로 DB에서 사용자 정보 select
- DB에 사용자 정보가 있는지 없는지 판단.
- DB에서 사용자의 권한그룹과 역할을 가져와 UserGrantedAuthority 객체를 생성
- DB에서 가져온 사용자 정보에서 계정 상태 여부를 판단.
- 비밀번호 만료 여부, 비밀번호 실패 여부 등 을 파악 해 CustomUserDetails에 세팅한다.
6-2. PreAccountStatusUserDetailsChecker.java
spring-security UserDetailsChecker 인터페이스를 따른다.
CustomUserDetailsService를 통해 DB에서 사용자 정보를 가져온 후, 비밀번호를 체크하기 전 AuthenticationChecker이다.
로그인 가능한 상태인지 체크한다.
@Slf4j
public class PreAccountStatusUserDetailsChecker implements UserDetailsChecker{
/**
* the list below is checked in order. in class User and CustomUserDetails.
* {@link CustomUserDetails}.acntStts > ACNT_STTS_CD.Waiting, Rejected
* {@link CustomUserDetails}.acntStts > ACNT_STTS_CD.Inactive
* {@link User}.accountNonLocked > {@link CustomUserDetails}.loginPrvntStts > LOGIN_PRVNT_STTS_CD.LockedByPassword
* {@link User}.enabled > {@link CustomUserDetails}.loginPrvntStts > LOGIN_PRVNT_STTS_CD.AccountDisabledByBlackList
* @param toCheck
*/
@Override
public void check(UserDetails toCheck) {
CustomUserDetails userDetails = (CustomUserDetails) toCheck;
Integer userSid = userDetails.getUser().getUserSid();
// 탈퇴
if(BooleanUtils.toBoolean(userDetails.getUser().getWhtdYn()))
{
throw new SurfinnUsernameNotFoundException("userSid : " + userSid);
}
// 계정 상태
switch (userDetails.getAcntStts()) {
// 승인 대기, 승인 거절
case Waiting:
case Rejected:
log.debug("Faild to authenticate since account authorization was not completed.");
throw new SurfinnNotApproveException("userSid : " + userSid + ", Faild to authenticate since account authorization was not completed.");
// 휴면
case Inactive:
log.debug("Faild to authenticate since account is inactive.");
throw new SurfinnInactiveException("userSid : " + userSid + ", Faild to authenticate since account is inactive.");
// 정상
case Active:
// 락
if(!userDetails.isAccountNonLocked())
{
// 비밀번호 연속 실패
if(LOGIN_PRVNT_STTS_CD.LockedByPassword == userDetails.getLoginPrvntStts())
{
log.debug("Faild to authenticate since account is locked. (password is incorrect.)");
throw new SurfinnLockedByPasswordException("Faild to authenticate since account is locked. (password is incorrect.)",null);
}
log.debug("Faild to authenticate since account is locked.");
throw new SurfinnLockedException("Faild to authenticate since account is locked.",null);
}
// 비활성화
if(!userDetails.isEnabled())
{
// 블랙리스트
if(LOGIN_PRVNT_STTS_CD.AccountDisabledByBalckList == userDetails.getLoginPrvntStts())
{
log.debug("Faild to authenticate since account is disabled. (black list account.)");
throw new SurfinnAccountDisabledBlackListException("Faild to authenticate since account is disabled. (black list account.)",null);
}
log.debug("Faild to authenticate since account is disabled.");
throw new SurfinnDisabledException("Faild to authenticate since account is disabled.",null);
}
break;
// 알수없음
default:
throw new SurfinnAuthenticationException(userDetails.getAcntStts() + " value is not included in 'ACNT_STTS_CD'.");
}
}
}
6-3. PostAccountStatusUserDetailsChecker.java
spring-security의 UserDetailsChecker 인터페이스를 따르며, 비밀번호 체크 이후 권한체크하는 Checker이다.
이곳에서는 커스텀만 하두었고 따로 기능을 추가하지 않았다.
@Slf4j
public class PostAccountStatusUserDetailsChecker implements UserDetailsChecker{
/**
* the list below is checked in order. in class User and CustomUserDetails.
* {@link User}.credentialsNonExpired
* @param toCheck
*/
@Override
public void check(UserDetails toCheck) {
}
}
6-4. CustomAuthenticationProvider.java
위 CustomUserDetailsService, PerAccountStatusUserDetailsChecker, PostAccountStatusUserDtailsChecker를 가지고 시큐리티 필터체인으로 인해 실행될 세션-쿠키 기반 폼로그인 인증 Provider를 커스텀한 것이다.
@Slf4j
public class CustomAuthenticationProvider extends DaoAuthenticationProvider
{
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
log.debug("Failed to check credentials. this value is null.");
throw new SurfinnBadCredentialsException("userSid : " + ((CustomUserDetails)userDetails).getUser().getUserSid());
}
String presentedPassword = authentication.getCredentials().toString();
if (!super.getPasswordEncoder().matches(presentedPassword, userDetails.getPassword())) {
log.debug("Failed to check credentials. not matches.");
throw new SurfinnBadCredentialsException("userSid : " + ((CustomUserDetails)userDetails).getUser().getUserSid());
}
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException
{
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
() -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports"));
String username = authentication.getName();
UserDetails user = super.getUserCache().getUserFromCache(username);
if (user == null) {
try {
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException ex) {
log.debug("Failed to find user '" + username + "'");
throw new SurfinnUsernameNotFoundException("Failed to find user '" + username + "'",ex);
}
Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
}
try {
// 승인, 휴면, 계정만료, 락, 비활성화
super.getPreAuthenticationChecks().check(user);
// 비밀번호 체크
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException ex) {
throw ex;
}
// 비밀번호 만료
super.getPostAuthenticationChecks().check(user);
return createSuccessAuthentication(user, authentication, user);
}
}
- CustomUserDetailsService를 통해 DB에서 사용자 정보를 가져온다.
- PreAccountStatusUserDetailsChecker를 통해 전처리 권한체크를 한다.
- 로그인 시도한 비밀번호와 DB에 저장된 비밀번호가 일치하는 지 체크한다.
- PostAccountStatusUserDetailsChecker를 통해 후처리 권한체크를 한다.
7. 로그인 인증 핸들러
7-1. LoginFailureHandler.java
로그인 인증 실패 핸들러이다.
spring-security의 AuthenticationFailureHandler 인터페이스를 따른다.
이곳에서 CustomUserDetails의 계정상태에 따라 로그인 실패 에러 메시지를 만든다.
@Slf4j
public class LoginFailureHandler implements AuthenticationFailureHandler {
@Value("${security.login.fail.redirect-uri.default}")
private String loginFailRedirectDefault;
@Value("${security.login.fail.imsi-lock.max-count}")
private Integer imsiLockMaxCnt;
@Value("${security.login.fail.imsi-lock.lock-minute}")
private long imsiLockLockMinute;
@Value("${security.login.fail.imsi-lock.use}")
private boolean imsiLockUse;
public LoginFailureHandler() {
}
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
String fowardPath = "/loginFailException";
try {
String key = "unknown";
if(exception instanceof SessionAuthenticationException)
{
key = AuthConstants.KEY_SESSION_AUTHENTICATION;
}
else if(exception instanceof SurfinnAuthenticationException)
{
key = ((SurfinnAuthenticationException) exception).getKey();
}
request.setAttribute("key", key);
} catch (Exception e) {
log.error(e.getMessage(), e);
}
request.getRequestDispatcher(fowardPath).forward(request, response);
}
}
AuthErrorController.java
LoginFailueHandler에서 포워드로 /loginFailException을 호출한다.
@Slf4j
@Controller
@RequiredArgsConstructor
public class AuthErrorController {
@Value("${security.login.fail.redirect-uri.default}")
private String loginFailRedirect;
@Value("${security.login.param.id}")
private String loginParamId;
private final AuthErrorService authErrorService;
private final RedirectExceptionProperties redirectExceptionProperties;
@PostMapping("/loginFailException")
private String sendRedirectwithErrorMessage(HttpServletRequest request, HttpServletResponse response,
RedirectAttributes redirectAuttribute) {
String key = (String) request.getAttribute("key");
String loginId = (String) request.getParameter(loginParamId);
String redirectUri = loginFailRedirect;
redirectAuttribute.addFlashAttribute("errorMessage",
authErrorService.getErrorMessageByAuthenticationException(key, loginId));
if (!ObjectUtils.isEmpty(redirectExceptionProperties.getUri(key))) {
redirectUri = redirectExceptionProperties.getUri(key);
}
log.debug("AuthErrorController :: key ("+ key+"), redirect ("+redirectUri+")");
return "redirect:" + redirectUri;
}
}
AuthErrorService.java
실제 에러메시지를 만드는 서비스이다. 메시지는 messageProperty로 관리한다.
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthErrorService {
@Value("${security.login.fail.imsi-lock.max-count}")
private Integer insiLockMaxCount;
@Value("${security.login.fail.imsi-lock.lock-minute}")
private Integer imsiLockLockMinute;
private final AuthUserService authUserService;
private final MessageProvider messageProvider;
public String getErrorMessageByAuthenticationException(String key, String loginId) {
switch (key) {
/**
* SurfinnApproveException '승인되지 않은 계정입니다. 관리자에게 문의 바랍니다.'
*/
case AuthConstants.KEY_NOT_APPROVE:
return messageProvider.getMessage(AuthConstants.MSG_USER_NO_APPROVAL,
messageProvider.getMessage(AuthConstants.MSG_CONTACT_ADMIN));
/**
* SurfinnInactiveException '휴면계정입니다.'
*/
case AuthConstants.KEY_INACTIVE:
return messageProvider.getMessage(AuthConstants.MSG_LOGIN_FAIL_INACTIVE);
/**
* SurfinnUsernameNotFoundException '아이디 또는 비밀번호가 올바르지 않습니다.'
*/
case AuthConstants.KEY_USER_NOT_FOUND:
return messageProvider.getMessage(AuthConstants.MSG_LOGIN_FAIL_DEFAULT);
/**
* SurfinnBadCredentialsException
* '아이디 또는 비밀번호가 올바르지 않습니다. ({0}회 이상 실패시 계정이 {1}동안 잠깁니다.)'
*/
case AuthConstants.KEY_BAD_CREDENTIALS:
return getErrorMassageBadCredential(key, loginId);
/**
* SurfinnLockedByPasswordException
* '{0}회 이상 로그인 실패로 계정이 잠겼습니다. {1} 후 재시도 바랍니다.'
*/
case AuthConstants.KEY_LOCKED_BY_PASSWORD:
return getErrorMassageLockedByPassword(loginId);
/**
* SurfinnLockedException '계정이 잠겼습니다. 관리자에게 문의 바랍니다.'
*/
case AuthConstants.KEY_LOCKED:
return messageProvider.getMessage(AuthConstants.MSG_LOGIN_FAIL_ACCOUNT_LOCK,
messageProvider.getMessage(AuthConstants.MSG_CONTACT_ADMIN));
/**
* SurfinnAccountDisabledBlackListException, SurfinnDisabledException
* '비활성화된 계정입니다. 관리자에게 문의 바랍니다.'
*/
case AuthConstants.KEY_DISABLED_BLACKLIST:
case AuthConstants.KEY_DISABLED:
return getErrorMassageDisabled(loginId);
/**
* SessionAuthenticationException
* '이미 다른 세션에서 로그인 중 입니다.'
*/
case AuthConstants.KEY_SESSION_AUTHENTICATION:
return messageProvider.getMessage(AuthConstants.MSG_LOGIN_FAIL_SESSION);
/**
* '로그인에 실패하였습니다. 관리자에게 문의 바랍니다.'
*/
default:
return messageProvider.getMessage(AuthConstants.MSG_LOGIN_FAIL_ETC,
messageProvider.getMessage(AuthConstants.MSG_CONTACT_ADMIN));
}
}
// 비밀번호 연속 실패 잠김 메시지
public String getErrorMassageLockedByPassword(String loginId) {
User user = authUserService.getUserByLoginId(loginId);
if (!ObjectUtils.isEmpty(user)) {
if (!ObjectUtils.isEmpty(user.getAcntLockDt())) {
if(ObjectUtils.isEmpty(imsiLockLockMinute))
{
imsiLockLockMinute = 0;
}
Date unLockDt = DateUtil.minuteAdd(user.getAcntLockDt(), imsiLockLockMinute);
double diffMin = (unLockDt.getTime() - user.getAcntLockDt().getTime()) / (double) DateUtil.MINUTE;
int min = (int) NumberUtil.ceil(diffMin);
String wait = (min < 1 ? messageProvider.getMessage(AuthConstants.MSG_WAIT_A_MINUTE)
: min + messageProvider.getMessage(AuthConstants.MSG_MINUTE));
return messageProvider.getMessage(AuthConstants.MSG_LOGIN_FAIL_ACCOUNT_LOCK_BY_PWD,
String.valueOf(insiLockMaxCount), wait);
}
}
return messageProvider.getMessage(AuthConstants.MSG_LOGIN_FAIL_ACCOUNT_LOCK_BY_PWD,
String.valueOf(insiLockMaxCount), messageProvider.getMessage(AuthConstants.MSG_WAIT_A_MINUTE));
}
// 비밀번호 실패 메시지
public String getErrorMassageBadCredential(String key, String loginId) {
StringBuffer sb = new StringBuffer();
User user = authUserService.getUserByLoginId(loginId);
try {
sb.append(messageProvider.getMessage(AuthConstants.MSG_LOGIN_FAIL_DEFAULT));
// 로그인 실패 카운트 증가
User result = authUserService.addLoginFailCount(user.getUserSid());
if (!ObjectUtils.isEmpty(result)) {
Integer cnt = insiLockMaxCount - result.getLoginFailCnt();
sb.append(String.format(" (%s)", (result.getLoginFailCnt() < insiLockMaxCount
? messageProvider.getMessage(AuthConstants.MSG_LOGIN_FAIL_COUNT_INFO, String.valueOf(cnt),
String.valueOf(imsiLockLockMinute)
+ messageProvider.getMessage(AuthConstants.MSG_MINUTE))
: messageProvider.getMessage(AuthConstants.MSG_LOGIN_FAIL_ACCOUNT_LOCK_BY_PWD,
String.valueOf(insiLockMaxCount), String.valueOf(imsiLockLockMinute)
+ messageProvider.getMessage(AuthConstants.MSG_MINUTE)))));
}
} catch (SurfinnAuthenticationException e) {
if(!e.getKey().equals(key))
{
sb.setLength(0);
sb.append(this.getErrorMessageByAuthenticationException(e.getKey(), loginId));
}
log.error(e.getMessage(), e);
} catch (Exception e) {
log.error(e.getMessage(), e);
}
return sb.toString();
}
// 비활성화 메시지
public String getErrorMassageDisabled(String loginId) {
User user = authUserService.getUserByLoginId(loginId);
String errorMessage = messageProvider.getMessage(AuthConstants.MSG_LOGIN_FAIL_DISABLED,
messageProvider.getMessage(AuthConstants.MSG_CONTACT_ADMIN),
(ObjectUtils.isEmpty(user.getAcntDsabldDt()) ? messageProvider.getMessage(AuthConstants.MSG_UNKOWN) : DateUtil.formatDate(user.getAcntDsabldDt(), AuthConstants.DATE_FM)));
return errorMessage;
}
}
7-2. LoginSuccessHandler.java
로그인 인증 성공 핸들러이다.
spring-security AuthenticationSuccessHandler 인터페이스를 따른다.
이곳에서 로그인 성공처리를한다. (계정상태 리셋, 에러세션 제거 등)
@Slf4j
public class LoginSuccessHandler implements AuthenticationSuccessHandler{
@Value("${security.credentials-expired.redirect}")
private String credentialsExpiredRedirect;
@Autowired
private AuthUserService authUserService;
@Autowired
private LoginFailureHandler loginFailureHandler;
private String redirectUri;
public LoginSuccessHandler(String redirectUrl)
{
this.redirectUri = redirectUrl;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
CustomUserDetails userDetails = (CustomUserDetails)authentication.getPrincipal();
try
{
// 에러세션 지우기
HttpSession session = request.getSession(false);
if(!ObjectUtils.isEmpty(session))
{
session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
}
// 계정 잠금 해제
authUserService.updateUserLoginSuccess(userDetails.getUser().getUserSid());
// 비밀번호 만료시 리다이렉트
String uri = redirectUri;
if(!userDetails.isCredentialsNonExpired() && !ObjectUtils.isEmpty(credentialsExpiredRedirect))
{
uri = credentialsExpiredRedirect;
}
response.sendRedirect(uri);
}
catch (Exception e) {
log.error(e.getMessage(),e);
loginFailureHandler.onAuthenticationFailure(request, response, new SurfinnAuthenticationException(null, e));
}
}
}
'기술 > Spring Security' 카테고리의 다른 글
[Spring security] 인증 및 권한 체크 (0) | 2021.10.24 |
---|---|
[Spring security] Authentication 라이브러리 구현 (0) | 2021.10.23 |
[Spring] Spring Security란? (3) | 2021.07.11 |