팀프로젝트/SpringBoot

스프링부트 팀플) 20240410 스프링시큐리티를 통한 로그인

일일일코_장민기 2024. 4. 10. 17:21
728x90

Spring Security란?
Spring Security는 인증, 권한 관리 그리고 데이터 보호 기능을 포함하여 웹 개발 과정에서 필수적인 사용자 관리 기능을 구현하는데 도움을 주는 Spring의 강력한 프레임워크이다.

 

일반적으로 개발 시 가장 먼저 작업하는 부분이 사용자 관리 부분으로 가볍게는 회원가입부터 로그인, 로그아웃, 세션 관리, 권한 관리까지 온라인 플랫폼에 맞춰 다양하게 작업되는 인가 & 보안 기능은 개발자에게 많은 시간을 요구한다.

 

Spring 생태계 내에서 이러한 요구사항을 효과적으로 지원하기 위해 개발된 것이 Spring Security로 개발자들이 보안 관련 기능을 효율적이고 신속하게 구현할 수 있도록 도와준다.


그런데 나는 이걸 회원가입부터 다 구현하고 난 다음에 맨 마지막에 이걸 구현하고 있다...
어려운 부분이니 어쩔 수 없지

 

Spring Security를 사용하는 이유?
자바 개발자들이 보안 기능을 추가할 때 Spring Security 사용하는 이유는 Spring Security가 Spring의 생태계에서 보안에 필요한 기능들을 제공하기 때문이다. Spring Security는 개발 구조가 Spring이라는 프레임워크 안에서 활용하기 적합한 구조로 설계되어 있어, 보안 기능을 추가할 때 활용하기 좋다. 

 

프레임워크를 사용하지 않고 코드를 직접 작성할 경우 Spring에서 추구하는 IoC/DI 패턴과 같은 확장 패턴을 염두 해서 인증/인가 부분을 직접 개발하기는 쉽지 않은데, Spring Security에서는 이와 같은 기능들을 제공해 주기 때문에 개발 작업 효율을 높일 수 있다.

 

때문에 많은 개발자들이 Spring을 사용할 경우에는 Spring Security를 활용하여 보안 기능을 추가하고 있으며, Spring Security에서 제공하는 기능 외에 추가적인 기능이 필요할 경우 Spring Security를 베이스로 기능을 추가하여 업무 효율을 높이고 있다.

 

쉽게 말해서 보안 때문에 사용한다.

그냥 로그인을 구현한다고 하면 안 쓰는 것이 편할 것 같지만, 보안을 중대사항이기 때문에 결코 그래서는 안된다.

 

 

기본적인 스프링시큐리티 아키텍처

붉은 박스가 스프링시큐리티가 작동되는 부분이다.

그림을 순서대로 살펴보면 아래의 과정과 같다.

 

1. 사용자의 요청이 서버로 들어옴

 

2. Authotication Filter가 요청을 가로채고 Authotication Manger로 요청을 위임함.

 

3. Authotication Manager는 등록된 Authotication Provider를 조회하며 인증을 요구. 

 

4. Authotication Provider가 실제 데이터를 조회하여 UserDetails 결과를 돌려줌.

 

5. 결과는 SecurityContextHolder에 저장이 되어 저장된 유저정보를 Spring Controller에서 사용할 수 있게 됨.

 

 

스프링시큐리티 내부 구조

1. 사용자가 자격 증명 정보를 제출하면, AbstractAuthenticationProcessingFilter가 Authentication 객체를 생성.

2. Authentication 객체가 AuthenticationManager에게 전달됨.

3. 인증에 실패하면, 로그인 된 유저정보가 저장된 SecurityContextHolder의 값이 지워지고 RememberMeService.joinFail()이 실행됨. + AuthenticationFailureHandler가 실행됨.

4. 인증에 성공하면, SessionAuthenticationStrategy가 새로운 로그인이 되었음을 알리고, Authentication 이 SecurityContextHolder에 저장됨.

5. SecurityContextPersistenceFilter가 SecurityContext를 HttpSession에 저장하면서 로그인 세션 정보가 저장됨.

6. RememberMeServices.loginSuccess()가 실행됨.

7. ApplicationEventPublisher가 InteractiveAuthenticationSuccessEvent를 발생시키고 AuthenticationSuccessHandler 가 실행됨

 

 

 

 

스프링시큐리티를 적용할 때는 크게 7군데를 작성/수정해야 한다.

 

1. SQL(작성 / 수정)

2. pom.xml(수정)

3. SpringSecurityConfig(작성)

4. SpringSecurityService(작성)

5. SpringSecurityDAO(작성 / 수정)

6. SpringSecurityUser(작성)

7. Mapper(수정)

8. jsp(수정)

 

DAO의 경우, 나 같은 경우에는 새로 만들었지만 특별한 기능을 하지는 않기 때문에 기존 코드에 수정해도 충분할 것이다.

 

1. SQL

drop table memberDB;

CREATE TABLE memberDB (
    userId VARCHAR2(50) PRIMARY KEY,
    userPw VARCHAR2(100) NOT NULL CHECK (LENGTHB(userPw) >= 4),
    nickname VARCHAR2(30) UNIQUE NOT NULL,
    secretCode VARCHAR2(50) UNIQUE not null,
    googleConnected NUMBER(1, 0) DEFAULT '0' CHECK (googleConnected IN ('1', '0')),
    naverConnected NUMBER(1, 0) DEFAULT '0' CHECK (naverConnected IN ('1', '0')),
    kakaoConnected NUMBER(1, 0) DEFAULT '0' CHECK (kakaoConnected IN ('1', '0')),
    userSignDate VARCHAR2(10) DEFAULT TO_CHAR(SYSDATE, 'yyyy/MM/dd') NOT NULL,
    role VARCHAR2(20) DEFAULT 'ROLE_MEMBER' NOT NULL,
    enabled VARCHAR2(20) DEFAULT 'True' NOT NULL CHECK (enabled IN ('True', 'False'))
);

 

해당 SQL을 작성할 때 몇 가지 유의사항이 있다.

1. 스프링시큐리티에서는 기본적으로 아이디의 변수명을 username, 패스워드의 변수명을 password로 사용한다.

단, 이는 다른 값을 써도 사용할 수 있게 만들 수 있다.

2. role은 스프링시큐리티에서 로그인한 유저가 어떤 직급에 있는지를 판단하기 위한 컬럼이다.

예를 들면, 일반회원, 관리자, 매니저 등등이 있다.

이때 role의 데이터값은 ROLE_ADMIN과 같은 형태로 사용하는데 ROLE_은 필수적으로 붙여야 하고 ADMIN의 대문자는 관례이다.

3. enabled는 로그인한 유저가 활동 중인지 정지 중인지 판단하기 위한 컬럼이다. 스프링시큐리티에서 활용 가능하다.

 

나머지는 우리 사이트에 맞춰진 컬럼들이니 신경 쓸 필요 없다.

 

 

 

2. pom.xml

<!-- 스프링 시큐리티 -->

<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-security</artifactId>

</dependency>

 

<!-- 테스트 시에만 사용되는 스프링 시큐리티의 테스트 지원 -->

<dependency>

<groupId>org.springframework.security</groupId>

<artifactId>spring-security-test</artifactId>

<scope>test</scope>

</dependency>

 

<!-- 필터 및 웹 보안 인프라 관련 코드 -->

<dependency>

<groupId>org.springframework.security</groupId>

<artifactId>spring-security-web</artifactId>

<version>5.7.7</version>

</dependency>

 

<!-- JSO에서 SpringSecurity Taglib 사용-->

<dependency>

<groupId>org.springframework.security</groupId>

<artifactId>spring-security-taglibs</artifactId>

<version>5.7.7</version>

</dependency>

 

참고로 소셜사이트 로그인을 사용하려면 다른 디펜던시가 추가로 필요하다.

어차피 다음 포스팅은 소셜사이트 스프링시큐리티 로그인이니, 그때 기록할 예정

 

 

3. SpringSecurityConfig

package com.moonBam.controller.springSecurity;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;


@SuppressWarnings("deprecation")
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{

	@Autowired
	private SpringSecurityService springSecurityService;
	
	@Bean
	public PasswordEncoder passwordEncoder() {
		return PasswordEncoderFactories.createDelegatingPasswordEncoder();
	}
	
	@Override
	protected void configure(HttpSecurity security) throws Exception {
		
		security.csrf().disable();
		
		//권한따라 허용되는 url 설정
		security.authorizeHttpRequests().antMatchers("/").permitAll();
//		security.authorizeHttpRequests().antMatchers("/memberList").hasRole("ADMIN");		//ROLE은 무조건 // ADMIN은 관례

		//로그인 설정
		security.formLogin().loginPage("/Login")
				.loginProcessingUrl("/loginProc")			//	/loginProc으로 Mapping
															//	자동으로 @Service가 붙은 Service 중에 loadUserByUsername가 있는 곳을 찾아가서 실행
															//	jsp에서 Method="POST", Action="/loginProc"으로 처리
				.usernameParameter("userId")				//	username Param을 userId로 사용 가능
				.passwordParameter("userPw")				//	password Param을 userPw로 사용 가능
//				.successHandler								//	로그인 성공 시, 작동(세션이랑 쿠키)
//              .failureHandler								//	로그인 실패 시, 작동
				.defaultSuccessUrl("/", true);				//	로그인 성공 시, 이동하는 URL

		//로그인 실패
		security.exceptionHandling().accessDeniedPage("/NotAuthentic");	//권한 없으면 가는 페이지

		//로그아웃
		security.logout().logoutUrl("/Logout").logoutSuccessUrl("/").invalidateHttpSession(true);
		//쿠키(토큰)/세션 삭제도 이곳에서 진행?
		
		
		
		security.userDetailsService(springSecurityService);
		
		
	}
	
    @Override
    public void configure(WebSecurity web) throws Exception {
    		web.ignoring().antMatchers("/static/**");
    }
}

 

우선 해당 파일은 Configuration 파일이다. (실행시킨 상태로는 수정이 반영되지 않는다.)

 

 

@EnableWebSecurity

스프링시큐리티 사용을 위한 어노테이션이다. 반드시 입력해야 한다.

 

extends WebSecurityConfigurerAdapter

아마 WebSecurityConfigurerAdapter에 취소줄이 그어질 것이다. 다른 방식으로 구현하는 것이 있다고는 알고 있는데 WebSecurityConfigurerAdapter를 상속받아 사용하는 것이 초보 개발자로서는 좀 더 쉬울 것이다.

 

@Autowired
private SpringSecurityService springSecurityService;

해당 서비스는 반드시 UserDetailsService 인터페이스를 사용하고 UserDetails를 리턴하는 클래스가 있는 Service이어야 한다.

 

@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

암호화를 위한 빈 등록

PasswordEncoder 안에 encode 함수를 통해 암호화하고 matches를 통해 암호화된 문자열과 입력한 문자열의 일치 여부를 판단한다.

 

security.csrf().disable();

csrf를 비활성화한다. 개발환경에서만 사용.

 

security.authorizeHttpRequests().antMatchers("/").permitAll();

모든 주소를 모든 사람에게 허가한다. 스프링시큐리티가 적용되지 않는 상태와 동일하다고 볼 수 있다.

포스팅하는 시점에서 다른 파트의 권한 분배에 대해 결론이 나지 않아 현재 이 상태로 사용 중이다.

 

security.authorizeHttpRequests().antMatchers("/memberList").hasRole("ADMIN");

/memberList 주소를 ROLE_ ADMIN을 가진 유저에게만 허락한다.

보통을 이렇게 개별 주소를 지정하기 보다는 /ADMIN/** 형태로 많이 사용한다. 

 

.formLogin().loginPage("/Login")

로그인 주소를 설정한다. .loginPage("/Login")가 없다면 기본 로그인 주소로 연결된다.

한마디로 로그인페이지를 개발자가 만들지 않아도 사용할 수 있다는 뜻!

.loginPage("/Login")가 있다면 /Login이라는 매핑주소를 POST방식으로 사용한다. 쉽게 말해서 jsp의 Form에서 method는 "POST", ACTION="/Login"을 사용하면 연결되는 것이다.


.loginProcessingUrl("/loginProc")

일단 내가 알기로는 /loginProc으로 매핑주소를 정해야 UserDetailsService 인터페이스를 사용하고 UserDetails를 리턴하는 클래스가 있는 Service로 연결되는 것으로 알고 있다.


.usernameParameter("userId")

username Param을 userId로 사용 가능. SQL에서 말했던 다른 이름으로 아이디를 입력해도 사용하는 방법

 

.passwordParameter("userPw")

password Param을 userPw로 사용 가능.  SQL에서 말했던 다른 이름으로 비밀번호를 입력해도 사용하는 방법


.defaultSuccessUrl("/", true);

로그인 성공 시, 이동하는 매핑주소를 입력한다. 다른 컨트롤러에서 지정한 Mapping주소이다.

 

exceptionHandling().accessDeniedPage("/NotAuthentic")

유저가 페이지에 들어갔는데 권한이 없을 경우에 Redirect되는 곳의 매핑주소를 입력한다.

쉽게 말해서 ROLE_MEMBER의 유저가 ROLE_ADMIN만 들어갈 수 있는 페이지에 진입할 경우, 발동된다.

 

.logout().logoutUrl("/Logout")

로그아웃을 하면 이동되는 매핑주소를 입력한다. 해당 매핑주소는 jsp에서 입력한다.

 

.logoutSuccessUrl("/")

로그아웃이 끝나면 이동되는 매핑주소를 입력한다.

 

.invalidateHttpSession(true);

로그아웃이 되면 사용하고 있던 세션을 모두 삭제한다.

 

.userDetailsService(springSecurityService);

사용자 지정 Service를 사용하기 위한 코드이다.

 

@Override
    public void configure(WebSecurity web) throws Exception {
     web.ignoring().antMatchers("/static/**");
    }

해당 주소의 컨텐츠는 스프링시큐리티 제어와 무관하게 작동된다. 주로 이미지, JS 등의 파일이 있는 곳을 지정한다.

지정하는 이유는 이런 정적 리소스를 제어하면 화면에 출력되지 않게 되어버리기 떄문이다.쉽게 말해서 로그인 안 해도 보여야 하는 이미지가 로그인해야만 보이게 되는 것을 방지한다.

 

 

4. Service

package com.moonBam.controller.springSecurity;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.moonBam.dto.MemberDTO;

@Service
public class SpringSecurityService  implements UserDetailsService {
	@Autowired
	SpringSecurityDAO dao;

	@Override
	public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
		MemberDTO dto= dao.userDetail(userId);
		
		if(!dto.getEnabled().equals("True")) {
			throw new UsernameNotFoundException(userId+" 활동 정지");	
		}
		if(dto==null) {  //사용자가 없는 경우
			throw new UsernameNotFoundException(userId+" 사용자없음");	
		}else {
			
			return new SpringSecurityUser(dto);
		}		
	}
}

 

MemberDTO dto= dao.userDetail(userId);

아이디를 통해 DB에서 유저 정보를 불러오는 코드이다.

 

이렇게 사용하는 이유는 스프링시큐리티 암호화와 관련이 있다.

스프링시큐리티에서 사용하는 bcrypt암호는 단방향 암호화로 우선 복호화가 되지 않는다. 또한 같은 글자라도 매번 다른 암호로 암호화가 된다. 이런 특성으로 인해 로그인할 때 아이디, 비밀번호를 모두 입력해도 아이디로만 유저 데이터를 찾아오고, 그 다음에 입력된 비밀번호와 유저 DB에 있는 비밀번호가 동일한지 판단하는 matches 메소드를 사용해야 한다.

 

5. DAO

package com.moonBam.controller.springSecurity;

import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

import com.moonBam.dto.MemberDTO;


@Repository
public class SpringSecurityDAO {
	@Autowired
	SqlSessionTemplate session;
	
	
	public MemberDTO userDetail(String userId) {
			System.out.println("userDetail1: 들어가는 데이터 :"+ userId);
			MemberDTO dto= session.selectOne("userDetail", userId);
			System.out.println("userDetail1: 나오는 데이터: "+ dto);
		return dto;
	}
}

DAO는 설명 생략

 

 

6. User

package com.moonBam.controller.springSecurity;

import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;

import com.moonBam.dto.MemberDTO;


public class SpringSecurityUser extends User{

	private static final long serialVersionUID = 1L;

	public SpringSecurityUser(MemberDTO dto) {
		
		super(	dto.getUserId(), 
				dto.getUserPw(), 
				AuthorityUtils.createAuthorityList(dto.getRole().toString()));
	}

}

 

User를 상속 받아 아이디, 비밀번호, Role들을 가져온다.

User 클래스를 들어가보면 SQL을 만들 때 입력했던 username, password, enable 등의 변수가 있음을 확인할 수 있다.

 

 

7. Mapper

<!-- Spring Security용 -->
	<select id="userDetail" resultType="com.moonBam.dto.MemberDTO" parameterType="String">
	  select * from  memberDB
	   where userId= #{userId}
	</select>

Mapper도 설명 생략

 

 

8. jsp 사용 예시

 

로그인 기능을 사용할 경우

<form id="loginForm" action="<c:url value='loginProc'/>" method="post">

 

로그인 화면에서 ajax를 사용할 경우

@Autowired

PasswordEncoder encoder;

 

//메인에서 로그인 여부 확인 에이젝스

@PostMapping("AjaxCheckIDPW")

public String AjaxCheckIDPW(String userId, String userPw) throws NoSuchAlgorithmException,       UnsupportedEncodingException, GeneralSecurityException {

 

String mesg = "loginSuccess";

 

// 로그인 에이젝스 실행 시 입력한 아이디와 비밀번호 출력

// System.out.println(userId);

// System.out.println(userPw);

 

// 아이디로만 확인해봤을 때 데이터가 있는지 확인(비밀번호는 매번 바뀌기 때문에 사용 불가)

MemberDTO dto= dao.userDetail(userId);

 

// 아이디가 없을 경우에는 바로 Ajax 종료

if(dto==null) {

return "loginFail";

}

 

// 아이디가 있을 경우

// DB에 입력된 비밀번호 출력

// System.out.println("dto에 저장된 암호: "+dto.getUserPw());

 

// False면 활동 정지 상태

if (!dto.getEnabled().equals("True")) {

return "suspendedId";

}

 

 

// 입력한 비밀번호와 DB의 비밀번호가 match되는지 확인(인코딩되지 않은 입력 그대로의 비밀번호, DB의 비밀번호)

boolean canLogin = encoder.matches(userPw, dto.getUserPw());

// System.out.println(canLogin);

 

// False면 Ajax로 인한 메세지 출력

if (!canLogin) {

mesg = "loginFail";

}

 

// True면 Submit 정상 진행

return mesg;

}

 

여기서 encoder의 matches를 사용할 때는 (입력한 비밀번호, DB의 인코딩된 비밀번호) 순서로 해야 한다.

 

 

또한 jsp에서 taglib을 사용하여 권한에 따라 다르게 랜더링하는 것도 가능하다.

<%@ page import="java.text.SimpleDateFormat"%>

<%@ page import="com.moonBam.dto.MemberDTO"%>

<%@ page import="java.util.List"%>

<%@ page language="java" contentType="text/html; charset=UTF-8"

pageEncoding="UTF-8"%>

<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c"%>

<%@ taglib uri="http://java.sun.com/jsp/jstl/functions" prefix="fn"%>

<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %>

 

<!DOCTYPE html>

<html>

 

<!-- 디버그를 위한 회원 리스트 출력 페이지의 jsp -->

 

<head>

<meta charset="UTF-8">

<title>회원 리스트(테스트용)</title>

<script

src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.4/jquery.min.js"></script>

 

</head>

<body>

 

<sec:authorize access="hasAnyRole('ROLE_ADMIN')">

<h1>[회원 목록]</h1>

<hr>

<table border=1>

<tr>

<th>아이디</th>

<th>비밀번호(클릭해도 확인불가)</th>

<th>닉네임</th>

<th>보안코드</th>

<th>구글 연동 여부</th>

<th>네이버 연동 여부</th>

<th>카카오 연동 여부</th>

<th>가입일</th>

<th>역할</th>

<th>상태</th>

<th>삭제</th>

</tr>

<c:forEach var="dto" items="${memberList}">

<tr>

<td>${dto.userId}</td>

<td>${dto.getUserPw()}</td>

<td>${dto.getNickname()}</td>

<td>${dto.getSecretCode()}</td>

<td>${dto.getGoogleConnected()}</td>

<td>${dto.getNaverConnected()}</td>

<td>${dto.getKakaoConnected()}</td>

<td>${dto.getUserSignDate()}</td>

<th>${dto.getRole()}</th>

<th>${dto.getEnabled()}</th>

<td><button class="deleteBtn" data-id="${dto.getUserId()}">삭제(참조 시 X)</button></td>

</tr>

</c:forEach>

</table>

 

<script type="text/javascript">

$(function() {

 

$(".deleteBtn").on("click", function() {

var userId = $(this).attr("data-id");

var tr = $(this)

 

$.ajax({

type: "GET",

url: "<c:url value='/IDDelete'/>",

data: {

userId : userId

},

dataType: "text",

success: function(){

tr.closest("tr").remove();

},

error: function(xhr, status, error){

console.log(error)

}

})

})

})

</script>

</sec:authorize>

 

<!-- ******************************************************************************************* -->

 

<sec:authorize access="!hasAnyRole('ROLE_ADMIN')">

<h1>[권한 없음]</h1>

<hr>

<div>이곳은 제한된 페이지입니다. 권한이 있음에도 페이지가 보이지 않을 경우, 관리자에게 문의하세요.</div>

</sec:authorize>

 

<div id="sitesShortCut">

<a href="<%=request.getContextPath()%>/Login">로그인 화면</a>

</div>

 

</body>

</html>

 

주석으로 만든 줄을 기준으로 위는 ADMIN 권한일 때, 아래는 ADMIN 권한이 아닐 때 랜더링되도록 만든 것이다. jstl의 choose와 유사하다.

 

 

 

 

다음 포스팅은 소셜사이트 로그인 시의 스프링시큐리티에 대해서이다.

슬쩍 해봤는데 SpringSecurityConfig와 User을 새로 구현하는 수준이라서 매우 슬프다...