2024. 5. 31. 18:55ㆍ시큐리티
완벽하게 개념을 이해하는 것이 선행되어야 할 때도 있지만,
가끔은 누군가가 만들어 놓은 소스를 그대로 적용시켜 차근차근 이해하는 작업이 더 효과적일 때가 있다. 시큐리티를 한 번 사용해보고 싶다면 읽어보길 바란다.
(개념, 어떤 방식으로 구현한 건지, 메서드에 대한 설명은 없으니, 만약 전체적인 틀과 방향 그리고 각각의 메서드를 이해하고 싶다면 읽지 않아도 좋을 듯하다. 이 글은 나처럼 시큐리티를 일단 구현해보고 뭐가 문제인지, 이게 왜 이렇게 작동하는지 차근차근 검색을 통해 알아가고자 하는 사람을 위해 썼다. +실수와 이해의 과정이 들어 있다.)
일단 구현이 가능해야 뭐가 어떻게 작동하는 지 제대로 이해하지 않을까?
build.gradle
plugins {
id 'java'
id 'war'
id 'org.springframework.boot' version '2.7.5'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'net.daum'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
/* 최초 생성한 스프링부트 3.0 그루비 gradle에서 : gradle(groovy), war, 자바 17, java언어로 선택함. */
dependencies {//gradle 방식에서 의존성 주입부분이다.
//웹소켓!!! 채팅을 위해 새로 추가함
implementation 'org.springframework.boot:spring-boot-starter-websocket'
//json 데이터를 처리하기 위해
implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.3'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
//implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.3.1'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.oracle.database.jdbc:ojdbc11'
annotationProcessor 'org.projectlombok:lombok'
/* 스프링 부트 2.0 버전에서는 톰켓 내장 서버 부분 주석문 처리 안해도 되지만 스프링 부트 3.0에서는 톰켓 10버전과 서블릿 ,
JSTL을 사용하기 위해서는 이 부분을 주석문 처리해야 한다. */
providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
//testImplementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter-test:2.3.1'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
/* jsp 실행*/
implementation "org.apache.tomcat.embed:tomcat-embed-jasper"
implementation 'javax.servlet:jstl' //스프링부트 3.0 미만에서 JSTL실행.
/* 스프링부트 3.0 이상에서 서블릿과 JSTL 실행
implementation 'jakarta.servlet:jakarta.servlet-api'
implementation 'jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api'
implementation 'org.glassfish.web:jakarta.servlet.jsp.jstl'*/
/* build.gradle 수정하고 프로젝트명 또는
build.gradle 선택 -> 우클릭 -> 단축 팝업메뉴 -> Gradle -> Refresh Gradle Project를 해야 변경사항이 반영된다. */
/*java.lang.NoSuchMethodError: 'java.util.Set org.junit.platform.engine.TestDescriptor.getAncestors()'
JUnit 테스트 실행시 위 에러가 발생하는 이유는 JUnit 5.9.3에서는 org.junit.platform:junit-platform-engine:1.9.3 이 없어서이다.
그래서 아래것 의존성을 추가하면 에러가 발생하지 않는다. */
testImplementation 'org.junit.platform:junit-platform-launcher:1.9.3'
}
tasks.named('test') {
useJUnitPlatform()
}
application.properties
spring.application.name=Travel
#TomCat Port Number
server.port=
#Oracle Connect
spring.datasource.driver-class-name=oracle.jdbc.OracleDriver
spring.datasource.url=jdbc:oracle:thin:@localhost:1521:xe
spring.datasource.username=
spring.datasource.password=
#view page Path
spring.mvc.view.prefix=/WEB-INF/views/
#view page extension
spring.mvc.view.suffix=.jsp
#change upload file size
#업로드하는 각 파일의 최대 크기
spring.servlet.multipart.max-file-size=100MB
#요청으로 보낼 수 있는 최대 요청의 크기
spring.servlet.multipart.max-request-size=100MB
#table create/update =>create는 기존테이블을 삭제후 다시 생성, update는 변경된 부분만 반영
spring.jpa.hibernate.ddl-auto=update
#ddl => DDL 데이터 정의어인 create,alter(테이블 수정문),drop,truncate(전체 레코드 삭제문),rename(테이블명,컬럼명 변경문)문 사용시 데이터베이스 고유 기능을
# 사용하겠는가?
spring.jpa.generate-ddl=true
#sql show => 실행되는 SQL문을 보여줄 것인가?
spring.jpa.show-sql=true
#database select => 데이터베이스 선택
spring.jpa.database=oracle
#log
logging.level.org.hibernate=info
#oracle 상세지정
spring.jpa.database-platform=org.hibernate.dialect.Oracle12cDialect
#spring Web Log
#logging.level.org.springframework.web=debug
#Spring Security Log show
#logging.level.org.springframework.security=debug
#MyBatis
#mybatis.config-location=classpath:mybatis-config.xml
#mybatis.mapper-locations=classpath:net/daum/mappers/**/*.xml
기본 설정이라고 복붙만 하지 말고, 꼭 한 번 읽어보길 바란다.
그냥 복붙만 했다가는 분명 설정에서 에러가 날 수 밖에 없을 거다.
(자신과 맞지 않는 부분(mybatis와 jpa 등등)은 주석문 보고 수정하면 될 듯 하다)
package net.daum.security;
import java.util.UUID;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
DataSource dataSource;
@Autowired
TravleUserService travleUserService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public CustomAuthenticationFailureHandler customAuthenticationFailureHandler() {
return new CustomAuthenticationFailureHandler();
}//로그인 실패 경우를 처리하는 핸들러
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/Alert","/logout").access("hasRole('ADMIN') or hasRole('NOPAIDUSER')")
.antMatchers("/admin/**").hasRole("ADMIN")
.and()
.formLogin()
.loginPage("/login")
.usernameParameter("member_id")
.passwordParameter("member_pwd")
.loginProcessingUrl("/login_ok")
.defaultSuccessUrl("/homepage")
.failureHandler(customAuthenticationFailureHandler())
.permitAll()
.and()
.logout()
.logoutUrl("/logout_ok")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
.logoutSuccessUrl("/homepage")
.permitAll();
http.exceptionHandling().accessDeniedPage("/accessDenied");
//403 접근 금지 에러가 났을때 실행
String rememberMeKey = UUID.randomUUID().toString();
//복잡한 키를 생성
http.rememberMe().key(rememberMeKey).userDetailsService(travleUserService)
.tokenRepository(getJDBCRepository())
.tokenValiditySeconds(60*60*24);
}
private PersistentTokenRepository getJDBCRepository() {
JdbcTokenRepositoryImpl repo=new JdbcTokenRepositoryImpl();
repo.setDataSource(dataSource);
return repo;
}
}
.antMatchers("/admin/**").hasRole("ADMIN")
힘들게 했던 부분이다. 지나고 보면 말도 안되는? 실수이긴 한데, 분명 나 같은 사람이 있겠지.
"/admin/**"
이 부분은 (ㅋㅋㅋ)파일 경로가 아니다... 나는 관리자를 위한 폴더를 만들어서 그 하위 파일을 관리자 권한만 접근하게 만들었다. 그 파일 경로는 컨트롤러에서 잘 처리하시고... 여기서 작성한 경로는 컨트롤러를 통한 뷰페이지 접근에 해당한다. 따라서
@RequestMapping("/admin/*")
@Controller
public class AdminController{...}
위와 같이 관리자 컨트롤러 전체 경로를 설정해두면 제대로 권한에 따른 접근이 가능해진다.
다음은
.usernameParameter("member_id")
.passwordParameter("member_pwd")
공부해보면 알게 되겠지만 기본적으로 시큐리티에서 사용자에 대한 아이디와 비밀번호에 대한 변수명?이 정해져 있다. 따라서 내가 작성한 이름과 다르다면 꼭 설정하자.
프레임워크를 통해 정해진 방식 안에서 코드를 작성하는 스프링 부트와 마찬가지로, 시큐리티 역시 내부적으로 기능들이 무수히 많다. 위 코드들에 적힌 "/매핑 주소" 는 내가 직접 작성한 것들도 있지만 아예 작성하지 않고 단순히 post로 값만 전달해준 것도 있다. 예를 들어
.loginProcessingUrl("/login_ok")
이거는 내가 직접 jsp 파일을 만들지도 않았고, 컨트롤러를 만들지도 않았다. 시큐리티가 내부적으로 처리해줬다. 하나씩 짚어가면서 기능에 맞는 jsp파일, 컨트롤러 등등을 작성하다보면 어느 순간 이해가 되는 지점이 있으리라 믿는다.
다음은
.logoutUrl("/logout_ok")
로그아웃 버튼을 누르면 처리되게끔 아주 단순하게 구현했다.
하지만 문제는 비밀번호 수정과 회원 탈퇴에서 일어났다. 로그아웃 페이지에서 로그아웃 버튼을 누르는 것과 별개로 비밀번호 수정과 회원 탈퇴에서는 먼저 컨트롤러에서 관련 정보를 확인한 다음 바로 로그아웃을 시켜야 했다. 때문에 로그아웃 페이지로 넘어가서 로그아웃 버튼을 누르는 미친 짓은 할 수 없었다. 그래서 어떻게든 컨트롤러에서 /logout_ok로 넘어가려고 했지만 컨트롤러에서 활용할 수 있는 방식은 전부 get 방식이라 요청이 거부됐다. 그래서
<body>
<form id="logoutForm" action="logout_ok" method="post">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />
<br>
<input type="submit" value="Logout">
</form>
<script type="text/javascript">
$(document).ready(function(){
$("#logoutForm").submit(); // 즉시 전송
});
</script>
</body>
위와 같이 post 방식으로 시큐리티로 넘길 수 있는 jsp 파일을 만들어서 해결했다.
위 코드를 읽었다면 csrf에 대해 궁금할 텐데, 여기서는 위와 같이 post 요청을 보낼 때 꼭 같이 보내야 한다는 사실만 기억하길 바란다. 또한
//서버로 아이디를 보내 중복체크
$.ajax({
type:"POST",
url:"Idcheck",
beforeSend: function(xhr){
xhr.setRequestHeader(header, token);
},
//아작스 요청을 보내기 전에 헤더에 토큰을 담아놓는다.
아작스 요청 시에도 위와 같은 방법으로 보내야 하며, 그 아작스를 보내는 해당 뷰페이지에서
<meta name="_csrf_header" content="${_csrf.headerName}">
<meta name="_csrf" content="${_csrf.token}">
코드를 꼭 <head></head> 안에 담아야 한다.
package net.daum.security;
import java.util.ArrayList;
import java.util.List;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import lombok.Getter;
import lombok.Setter;
import net.daum.vo.MemberVO;
@Setter
@Getter
public class TravleSecurityUser extends User {
private static final String ROLE_PREFIX = "ROLE_";
//권한을 ROLE_ 로 하는 것이 관례화. hasRole() 앞부분에도 자동으로 설정되어 있음.
private MemberVO member;
@Override
public void eraseCredentials() {
// this.password = null;
// 기본으로 위 코드가 되어 있기 때문에, 오버라이딩해서 지워주면 컨트롤러에서도 비밀번호를 받을 수 있음.
// 하지만 보안에 좋지 않으니 이렇게 사용하지 말것.
}
public TravleSecurityUser(MemberVO member, HttpServletRequest request) {
super(member.getMember_id(), member.getMember_pwd(), makeGrantedAuthority(member.getRole()));
HttpSession session = request.getSession(); // 세션 객체 생성
session.setAttribute("id", member.getMember_id());
session.setAttribute("name", member.getMember_name());
session.setAttribute("auth", ROLE_PREFIX + member.getRole());
}
private static List<GrantedAuthority> makeGrantedAuthority(String role) {
List<GrantedAuthority> list = new ArrayList<>();
list.add(new SimpleGrantedAuthority(ROLE_PREFIX + role));
return list;
}
}
로그인 이후에
비밀번호 수정, 회원정보 수정, 회원탈퇴...
아이디와 비밀번호가 필요한 경우가 많은데, 로그인할 때 우리는 이미 아이디와 비밀번호 그리고 권한을 저장해놨기 때문에 관련 로직을 처리하기가 상당히 수월하다. 아이디만 불러올 때는 아무런 이상이 없었는데, 비밀번호를 불러오려고 하자 계속 null 값을 불러왔다.
@GetMapping("/Alert")
public ModelAndView Alert(@AuthenticationPrincipal UserDetails userDetails) {
String username=userDetails.getUsername();
String[] email= {"gmail.com","naver.com","daum.net","직접입력"};
MemberVO m= this.memberService.idCheck(username);
ModelAndView home=new ModelAndView();
home.setViewName("jsp/alert");
home.addObject("email",email);
home.addObject("m",m);
return home;
}
@AuthenticationPrincipal UserDetails userDetails
위 방식으로 우리는 아이디를 가져올 순 있지만, 비밀번호를 가져올 순 없다.
@Override
public void eraseCredentials() {
// this.password = null;
// 기본으로 위 코드가 되어 있기 때문에, 오버라이딩해서 지워주면 컨트롤러에서도 비밀번호를 받을 수 있음.
// 하지만 보안에 좋지 않으니 이렇게 사용하지 말것.
}
해당 메서드를 읽어보면 바로 이해가 가능할 거다.
참고로 해당 클래스에서 비밀번호 정보를 불러와보면 null 값이 아니다.
하여튼 위 방법을 사용하면 쉽게 비밀번호를 불러와서 회원 정보에 관한 로직을 처리할 수 있지만 그건 보안상 좋지 않다고 하니, 위와 같은 메서드와 방법이 있다고만 알아 두고, 아이디 불러와서 관련 회원 정보를 꺼내오도록 하자.
package net.daum.security;
import javax.servlet.http.HttpServletRequest;
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 net.daum.dao.MemberRepository;
@Service
public class TravleUserService implements UserDetailsService {
@Autowired
private MemberRepository memberRepo;
@Autowired
private HttpServletRequest request;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return this.memberRepo.findById(username)
.filter(member -> member != null)
.map(member -> new TravleSecurityUser(member, request))
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
}
}
어렵지 않으니 넘어가자.
package net.daum.security;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
request.getSession().invalidate();
response.sendRedirect("/login?error=true");
}
}
위 코드는 로그인을 진행했을 때 겪었던 어이없는? 문제로 작성한 코드다.
위에서 말했듯이 성공적인 로그인에 대한 판단을 내가 직접 처리했다기 보다는 시큐리티가 내부적으로 수행했기 때문에 벌어진 일이다. 아이디 혹은 비밀번호가 틀려서 로그인에 실패한 이후 (아무 이유 없이)다시 홈페이지로 돌아가 봤는데, 로그인이 되어 있었다. 세션에 문제가 있다고 생각해 (실패한 건 실패한 거니까)로그인 실패에 따른 핸들러를 만들어
window.onload = function() {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('error')) {
alert('가입된 회원이 아닙니다.\n아이디와 비밀번호를 다시 확인하세요.');
}
}
위 코드와 결합해 로그인이 불가능하게 만들었다.
차근차근 소스를 작성하고 확인하면서 홈페이지를 구동했는데, 계속 드는 의문이 있었다. 바로 쿠키 저장이 되지 않는 것...
<tr>
<td colspan="2">
로그인 유지 :
<input type="checkbox" id="remember-me" name="remember-me">
</td>
</tr>
위 SecurityConfig에 관련된 코드가 있으니 참고하길 바란다.
하지만
이래도 쿠키 저장은 될리가 없다.
...왜냐하면
package net.daum.vo;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import org.hibernate.annotations.CreationTimestamp;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.io.Serializable;
import java.sql.Timestamp;
@Setter
@Getter
@ToString
@Entity
@Table(name = "persistent_logins")
//시큐리티가 자동으로 인식하는 테이블 명 persistent_logins
//persistent_logins 가 아닌 다른 테이블 명을 쓴다면 시큐리티에서 테이블명 세팅을 해줘야 한다.
@EqualsAndHashCode(of="series")
public class RememberKey implements Serializable {
private static final long serialVersionUID = 1L;
@Id
private String series;
private String username;
private String token;
@CreationTimestamp
private Timestamp lastUsed;
//@ToString을 쓰지 않았을 때 오버라이드 한다면
// @Override
// public String toString() {
// return "RememberKey{" +
// "series='" + series + '\'' +
// ", username='" + username + '\'' +
// ", token='" + token + '\'' +
// ", lastUsed=" + lastUsed +
// '}';
// }
}
토큰을 저장해놓을 테이블을 만들어 주자.
혹시 모르니 유저에 대한 클래스에 대해 설명하자면
package net.daum.vo;
...
public class MemberVO {
@Id
private String member_id;
private String member_name;
...
private String role="NOPAIDUSER";
@CreationTimestamp
private Timestamp member_joinDate;
}
(대부분 리스트와 또 다른 테이블을 활용해서 권한에 대한 설정을 해주던데... 굳이? 라는 생각이 들어 기본값을 설정해두고 조건에 따라 변경해주는 방식으로 진행했다. )
여튼 내 코드를 따라해 본다면 위처럼 회원가입한 유저에 대해 기본값을 설정해주자.
다른 블로그, 개발자들에 비하면 비교적 간단?한 코드와 로직일 수도 있지만 이정도까지 알아내고 직접 적용하는 것마저도 아직까진 버겁기만 하다. 처음엔 마치 뉴스에 나오는 엄청난 해커들에 맞서 사이버 보안을 적용하는 거라 생각했다가, 인증과 인가와 같은 기본적인 개념에 대해, 그러고 나선 자연스레 csrf, 토큰, 세션 등등에 대해 알게 되는 과정이었다. 시큐리티는 참 재밌기도 하면서 어려운 개념이다.
어떻게 보면 자바 스크립트, if 조건문, 정규식을 활용해서 간단하게 회원가입과 로그인 과정에서 유효성 검증을 하는 것 역시 시큐리티의 일부분이지 않을까 싶다.
더 깊게 공부해서 코드를 발전시켜봐야겠다. 언젠가 또 시큐리티에 대한 글을 쓸 날이 오지 않을까.
'시큐리티' 카테고리의 다른 글
[시큐리티] 유저의 권한을 변경하자 (0) | 2024.06.12 |
---|