로그인을 처리할 때 고려해야 할 점은, 단순히 사용자로부터 아이디와 비밀번호를 입력받아 그 값을 DB 내 사용자 테이블의 데이터와의 일치여부를 판단하는 인증 기능 하나만으로 끝이나지 않는다.
그렇게 로그인이 되었다면 독립적인 HTML 파일로 존재하는 모든 웹 페이지에서 로그인 정보를 유지할 수 있도록 세션을 유지해야 하고, 필요하다면 인가 기능을 통해 해당 사용자에게 보여줄 페이지와, 보여줘서는 안 될 페이지를 나눠야 한다.
이러한 작업은 물론 직접 코딩을 통해 구현할 수 있겠지만, 매우 귀찮은 작업이다. 그런데 Spring Security는 이러한 귀찮은 작업을 쉽게 구현할 수 있도록 도와준다.
Spring Security
Spring Security는 스프링의 파위 프레임워크로, 어플리케이션의 보안을 담당한다. 그리고 이 보안에는 위에서 언급한 세션과, 인증 기능 등을 모두 포함하고 있다.
즉, 로그인과 회원 정보에 관련된 기능을 쉽고 간편하게 구현할 수 있게 해준다.
사실 깊게 설명을 하면 끝이 없고, 바로 구현으로 넘어가자.
당연히 구현 툴은 Spring Boot이다.
다음의 기능들을 넣고 프로젝트를 생성한다.
가장 먼저 늘 그랬듯 application.properties를 먼저 구성.
데이터베이스는 다음과 같이 생성한다.
간단하게 설명을 하자면,
사용자가 존재하고 사용자를 그룹으로 묶어 권한을 부여할 수 있는 권한이 존재한다.
권한은 메뉴와 다대다 관계이며, 이에 따라 관계 테이블을 통해 허용 여부를 가지고 있는 구조이다.
하지만 단순 연습 페이지에서 이 DB를 전부 구현하기는 귀찮으니, 사용자와 권한_메뉴_관계 테이블만 구성하자.
이에 따른 Domain 테이블은 아래와 같이 구성된다.
AuthDomain.java
package com.example.demo.domain.auth;
import java.util.Collection;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.IdClass;
import javax.persistence.Table;
import org.springframework.security.core.GrantedAuthority;
import com.example.demo.domain.user.UserDomain;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Data
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Table(name="auth_domain")
@IdClass(AuthDomainPk.class)
public class AuthDomain {
@Id
@Column(name="auth_id", columnDefinition="varchar(10)")
private String authId;
@Id
@Column(name="menu_id", columnDefinition="varchar(10)")
private String menuId;
@Column(name="permit", columnDefinition="boolean")
private Boolean permit;
}
AuthDomainPK.java
package com.example.demo.domain.auth;
import java.io.Serializable;
import javax.persistence.Column;
import javax.persistence.Id;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@ToString
public class AuthDomainPk implements Serializable {
@Id
@Column(name="auth_id", columnDefinition="varchar(10)")
private String authId;
@Id
@Column(name="menu_id", columnDefinition="varchar(10)")
private String menuId;
}
*Repository에서 사용하기 위해 Id를 별도로 선언한 클래스이다.
UserDomain.java
package com.example.demo.domain.user;
import java.util.Collection;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.persistence.Transient;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Data
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Table(name="user_domain")
public class UserDomain implements UserDetails {
@Id
@Column(name="id", columnDefinition="varchar(10)")
private String id;
@Column(name="password", columnDefinition="varchar(10)")
private String password;
@Column(name="auth_id", columnDefinition="varchar(10)")
private String authId;
@Transient
private Collection<? extends GrantedAuthority> authorities;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
/*
//lombok의 Getter로 자동생성되니 패스.
@Override
public String getPassword() {
return password;
}
*/
@Override
public String getUsername() {
return id;
}
@Override
public boolean isAccountNonExpired() {
//계정의 만료 여부
//true : 만료되지 않음
return true;
}
@Override
public boolean isAccountNonLocked() {
//계정의 잠김 여부
//true : 잠금되지 않음
return true;
}
@Override
public boolean isCredentialsNonExpired() {
//계정의 패스워드 만료 여부
//true : 만료되지 않음
return true;
}
@Override
public boolean isEnabled() {
//계정의 사용가능 여부
//true : 사용 가능
return true;
}
}
*참고로 Spring Security의 사용자 정보로 사용하기 위해서는 UserDetails를 상속받아야 한다.
여기까지만 만들어놓고 프로젝트를 실행시키고 /login으로 접속 시 다음의 화면이 떠오른다.
이는 기본적으로 Spring Security에서 제공하는 로그인 화면이다.
테스트 용으로는 좋지만, 실제 프로젝트에서 이를 사용할 수는 없으니 컨트롤러를 만들자.
login.html
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<input type="text" id="username" placeholder="아이디를 입력하세요."> <br>
<input type="password" id="password" placeholder="비밀번호를 입력하세요."> <br>
<input type="button" id="loginBtn" value="로그인"> <br>
</body>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script th:inline="javascript">
console.log([[${_csrf}]])
$("#loginBtn").on('click', function() {
userName = $("#username").val();
password = $("#password").val();
if(userName == "") {
Alert("아이디를 입력하세요.")
return
}
if(password == "") {
Alert("패스워드를 입력하세요.")
return
}
$.ajax({
type : "POST",
url : "/login",
data : {
[[${_csrf.parameterName}]] : [[${_csrf.token}]], //만약 넣어주지 않으면 에러가 발생한다. Logout도 마찬가지이다.
username : userName,
password : password
},
success : function(data, state, xhr) {
console.log(xhr)
if(xhr.getResponseHeader("isSuccess") == "true") {
location.replace("/home");
}
else if(xhr.getResponseHeader("isSuccess") == "false") {
//HM 접근 권한이 없을 경우
alert('접근 권한이 없습니다.');
}
else {
alert('로그인에 실패했습니다. 아이디 혹은 비밀번호를 확인하세요.');
}
},
error : function() {
}
})
})
</script>
</html>
원래는 FORM을 사용해야 하지만, Ajax를 사용해보았다.
그리고 Ajax의 특성 상 서버로부터 받는 redirect 신호는 받을 수 없으므로, 직접 location.replace를 활용해 리다이렉트 시켜준다.
만드는 김에 홈 화면과 에러 페이지도 만들자.
home.html
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<div>사용자 아이디 : <i th:text="${session.id}"></i></div>
<div>권한 목록 : <i th:text="${session.auth}"></i></div>
<br>
<input type="button" id="logOutBtn" value="로그아웃">
</body>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script th:inline="javascript">
$("#logOutBtn").on('click', function() {
$.ajax({
type : "POST",
url : "/logout",
data : {
[[${_csrf.parameterName}]] : [[${_csrf.token}]],
},
success : function() {
alert("로그아웃 되었습니다.")
location.replace("/login")
}
})
})
</script>
</html>
403.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
웹 페이지 접근 권한이 없습니다.
</body>
</html>
그 후에는 화면을 뿌려줄 컨트롤러를 만든다.
UserController.java
package com.example.demo.controller.user;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.servlet.ModelAndView;
@Controller
public class UserController {
@GetMapping("/home")
public ModelAndView getMainPage() {
ModelAndView mav = new ModelAndView("home");
return mav;
}
@GetMapping("/login")
public ModelAndView getLoginPage() {
ModelAndView mav = new ModelAndView("login");
return mav;
}
@GetMapping("/403")
public ModelAndView getErrPage() {
//연습 페이지에서는 login, home만 존재하고,
//home 접근 권한이 없을 경우 자동으로 세션이 만료되게 만들어 딱히 사용되진 않는다.
ModelAndView mav = new ModelAndView("403");
return mav;
}
}
그리고 Spring Security를 설정할 파일을 만들어야 하는데,
그 전에 앞서 서비스와 리포지토리, 각종 핸들러와 인코더를 만든다.
AuthRepository.java
package com.example.demo.repository.auth;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
import com.example.demo.domain.auth.AuthDomain;
import com.example.demo.domain.auth.AuthDomainPk;
@Repository
public interface AuthRepository extends CrudRepository<AuthDomain, AuthDomainPk>{
@Query
public Iterable<AuthDomain> findByAuthId(String authId);
@Query
public Iterable<AuthDomain> findByAuthIdAndPermitTrue(String authId);
}
UserRespotiroy.java
package com.example.demo.repository.user;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
import com.example.demo.domain.user.UserDomain;
@Repository
public interface UserRepository extends CrudRepository<UserDomain, String>{
}
UserService.java
package com.example.demo.service.user;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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.example.demo.domain.auth.AuthDomain;
import com.example.demo.domain.user.UserDomain;
import com.example.demo.repository.auth.AuthRepository;
import com.example.demo.repository.user.UserRepository;
@Service
public class UserService implements UserDetailsService {
@Autowired private UserRepository userRepo;
@Autowired private AuthRepository authRepo;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserDomain userDomain = userRepo.findById(username).orElseThrow(() -> new UsernameNotFoundException(username));
//권한 리스트 넣어주고 반환
try {
Collection<SimpleGrantedAuthority> authList = new ArrayList<>();
Iterator<AuthDomain> authItr = authRepo.findByAuthIdAndPermitTrue(userDomain.getAuthId()).iterator();
while(authItr.hasNext()) {
authList.add(new SimpleGrantedAuthority(authItr.next().getMenuId()));
}
userDomain.setAuthorities(authList);
}
catch (Exception e) { e.printStackTrace(); }
return userDomain;
}
}
*서비스는 기본적으로 UserDetailsService를 상속하여 만들어야 한다.
*loadUserByUsername 함수를 상속받아, username을 기반으로 해당 사용자를 찾아 리턴해줘야 한다.
LoginSuccessHandler.java
package com.example.demo.config;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import com.example.demo.domain.user.UserDomain;
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
HttpSession session = request.getSession();
session.setAttribute("id", authentication.getName()); //name을 가져와 세션에 저장한다.
//만약 다른 속성값을 가져오고 싶다면 아래와 같이 가져온다.
/*
session.setAttribute("name", ((UserDomain) authentication.getPrincipal()).getName());
*/
Object[] authObject = authentication.getAuthorities().toArray();
List<String> authList = new ArrayList<>();
for(Object auth : authObject) {
authList.add(auth.toString());
}
session.setAttribute("auth", authList);
//홈 (메인 페이지) 입장 권한이 없을 경우 세션을 삭제하고 헤더에 해당 내용을 지정하여 뷰에 알려준다.
if(!authList.contains("HM")) {
response.setHeader("isSuccess", "false");
SecurityContextHolder.clearContext();
}
else {
response.setHeader("isSuccess", "true");
}
}
}
*세션에 사용자 정보를 입력하기 위해 만든 핸들러이다.
NoPasswordEncoder.java
package com.example.demo.config;
import org.springframework.security.crypto.password.PasswordEncoder;
public class NoPasswordEncoder implements PasswordEncoder {
@Override
public String encode(CharSequence rawPassword) {
return rawPassword.toString();
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return rawPassword.toString().equals(encodedPassword);
}
}
*원래는 패스워드를 암호화할 인코더를 설정해주어야 하는데, 현재 실습에서는 회원가입 없이 DB에 직접 입력하기 위해 커스텀 인코더를 만들었다.
마지막으로 스프링 시큐리티의 설정 파일을 만든다.
SecurityConfig.java
package com.example.demo.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.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.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import com.example.demo.service.user.UserService;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
@Autowired private UserService userService;
@Bean
public PasswordEncoder passwordEncoder() {
return new NoPasswordEncoder();
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
@Bean
public AuthenticationSuccessHandler loginSuccessHandler() {
return new LoginSuccessHandler();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//사용할 서비스와 패스워드 인코더를 설정한다.
//굳이 회원가입 페이지를 별도로 만들지 않을 예정이므로 아무런 인코딩도 하지 않는 별도의 인코더를 별도로 선언하여 사용한다.
auth
.userDetailsService(userService)
.passwordEncoder(passwordEncoder());
}
@Override
public void configure(WebSecurity web) throws Exception {
//기본적으로 모든 url을 차단하는데, 그 중 제외할 것들을 선언한다.
//대부분 js와 css, font 등의 기능들을 여기서 해제하여 사용한다.
web
.ignoring()
.antMatchers("/css/**", "/js/**", "/fonts/**");
//여기서 **는 몇 차례의 depth도 허용함을 의미한다.
//만약 *만 선언 시,
// /css/a.css 는 허용되지만
// /css/css1/a.css 는 허용되지 않는다.
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/login").permitAll() //login 페이지는 누구든 접근할 수 있다.
.antMatchers("/logout").authenticated() //logout 페이지는 인증된 사용자만 접근이 가능하다.
.antMatchers("/home").hasAuthority("HM") //HM이라는 권한을 가진 사용자만 접근이 가능하다.
//.antMatchers("/home").hasAnyAuthority("A", "B") //A, B 중 어느 권한이라도 갖고 있다면 접근 가능.
.anyRequest().authenticated() //이외의 접근은 모두 인증된 상태여야 한다.
.and()
.formLogin()
.loginPage("/login") //사용자가 Login 페이지를 직접 지정한다.
.successHandler(loginSuccessHandler()) //로그인 성공 시 핸들러를 지정한다.
.and()
.logout()
.logoutSuccessUrl("/login") //로그아웃 성공 시 로그인 페이지로 이동
.invalidateHttpSession(true) //세션 삭제
.deleteCookies("JSESSIONID") //쿠키 삭제
.clearAuthentication(true)
.and()
.exceptionHandling()
.accessDeniedPage("/error/403") //인증 없이 진입하거나 해당 웹 페이지 권한이 없을 경우
.and()
.sessionManagement()
.maximumSessions(1) //인당 1 세션 허용
.maxSessionsPreventsLogin(true) //중복 로그인 시도 시 로그인 자체를 차단한다. false 시 이전 세션을 강제로 만료시킨다고 한다.
.expiredUrl("/login") //세션 만료 시 login 페이지로 이동.
.sessionRegistry(sessionRegistry());
}
}
*만약 csrf를 사용하고 싶지 않다면 http.csrf().disable()를 선언한다.
여기까지만 하고 돌려보자.
데이터는 아래와 같다.
'실습 > 리눅스 서버 + 스프링 부트' 카테고리의 다른 글
Scheduler (0) | 2022.01.15 |
---|---|
JPA (0) | 2021.11.27 |
HAProxy를 활용한 IFrame 활성화 (0) | 2021.09.26 |
[스프링부트] Thymeleaf (0) | 2021.09.26 |
09. MariaDB CRUD_UI (0) | 2021.09.18 |