diary 프로그램에 로그인 기능이 없어서 아무나 접근해서 읽기를 볼 수도 있고, 쓸 수도 있는 상황이야.
spring boot 의 Spring Security 기능을 이용해서 폼 로그인 기능을 붙여볼께.
https://start.spring.io 에서 Add Dependencies 로 security 를 검색하여 Spring Security 의존성을 추가해.
Explorer 버튼으로 수정된 build.gradle 파일의 내용에서 spring security 의존성 부분을 복사해서 프로젝트의 build.gradle 에 붙여넣기 한다.
build.gradle 변경된 것을 적용한다.
이 상태에서 실행시켜서 웹브라우저에 http://localhost:8080 으로 입력해보면 http://localhost:8080/login 으로 이동해서 로그인 화면이 표시가 된다.
서버 로그를 보면 패스워드가 표시가 되어 있어.
Username 에 user 를, Password 에는 로그에 표시된 패스워드값을 복사해서 붙여넣기하고 Sign in 버튼을 눌러보면 정상적으로 홈 화면이 표시되는걸 확인할 수 있지.
심지어 인증되지 않은 상태에서는 Rest API 호출도 401 Unauthorized 에러를 리턴하게 되지.
이것으로 build.gradle 에 추가한 implementation ‘org.springframework.boot:spring-boot-starter-security’ 이 한 줄이 내부적으로 인증과 관련된 무언가를 자동으로 해주게 된다는걸 알 수가 있어.
인증되지 않은 경우 인증을 위한 로그인 화면으로 이동시켜주고, 인증이 된 경우에만 endpoint 에 접근할 수 있도록 처리해 주는거지. 하지만, 기본적인 뼈대만 구성해준 것이지, 인증 로직은 우리가 직접 구현을 해 주어야 해.
이제 자동으로 무언가를 처리하고 있는 부분에 조금 관여를 해보자.
config 패키지를 새로 만들고 이 패키지에 SecurityConfig 클래스를 생성해볼께.
그리고 이 클래스에 @Configuration 과 @EnableWebSecurity 애노테이션을 추가해줘.
package com.woohahaapps.study.diary.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
}
이 클래스는 인증과 관련된 설정을 담당하게 될 클래스야. 이 클래스 안에 @Bean 이라는 애노테이션을 붙인 메소드를 추가하는 과정이 필요해.
spring boot 이전 버전에서는 WebSecurityConfigurerAdapter 클래스를 상속받아서 오버라이드 메소드를 만들었지만 WebSecurityConfigurerAdapter 클래스가 Deprecated 되어서(Spring Security 5.7.0-M2부터) 최신 버전의 spring boot 에서는 사용할 수가 없어.
가장 중요한 메소드는 SecurityFilterChain 을 리턴하는 메소드야.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity.build();
}
}
Security 를 직접 관리하기 위해서 추가한 SecurityConfig 클래스이기 때문에 @Configuration 애노테이션을 붙인 것이고, 이 안에서 직접 메소드를 정의하는 경우이기 때문에 @Bean 애노테이션을 붙여준거야.
HttpSecurity 클래스는 Builder 패턴이 적용되어 있는 클래스이기 때문에 설정을 모두 마치고 난 다음에 build() 메소드로 리턴을 해주고 있어. 이 상태로 프로그램을 빌드해서 실행시켜주면 아무런 설정도 하지 않은 상태이기 때문에 인증없이 모든 화면을 볼 수가 있게 돼.
HttpSecurity 클래스를 살펴보면 굉장히 많은 메소드들이 정의가 되어 있는걸 알 수 있어.
참고 : https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/config/annotation/web/builders/HttpSecurity.html
이제 HttpSecurity 클래스형 객체에 설정들을 추가해볼께.
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.build();
}
authorizeHttpRequests 는 인증 사용에 대한 설정이야. 파라미터로 anyRequest().authenticated() 를 사용하면 모든 페이지에 대해서 인증받은 경우만 사용할 수 있게 한다는 설정이야.
이 상태로 프로그램을 실행시켜보면 아까 봤던 로그인 페이지는 보이지 않고, 페이지를 볼 수 있는 권한이 없다는 403 에러가 표시되지. 인증되지 않았기 때문이야.
이제 Spring Security 가 제공하는 기본 로그인폼을 사용하는 설정을 추가해볼께.
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults())
.build();
}
formLogin(Customizer.withDefaults()) 는 기본 제공되는 폼 양식을 사용하는 설정이야. 개발자가 직접 디자인한 로그인폼을 사용하려면 아래와 같이 설정하면 돼.
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
)
.build();
}
프로그램을 실행시켜보면 /login 경로로 이동은 했지만, 아무것도 표시가 되지 않고 있지?
login 경로에 대한 핸들러를 작성하지 않았기 때문이지.
간단하게 bootstrap 을 이용해서 login 페이지를 만들고, /login 경로에 대한 핸들러를 작성해볼께.
templates/login.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<!-- 모바일에서의 적절한 반응형 동작을 위해 -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- -->
<title>Title</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous"
>
<!-- -->
<!-- head 태그에 다음의 코드를 삽입 -->
<style type="text/css">
html,
body {
height: 100%;
}
.form-signin {
max-width: 330px;
padding: 1rem;
}
.form-signin .form-floating:focus-within {
z-index: 2;
}
.form-signin input[type="email"] {
margin-bottom: -1px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.form-signin input[type="password"] {
margin-bottom: 10px;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
</style>
</head>
<body>
<main class="form-signin w-100 m-auto">
<form>
<h1 class="h3 mb-3 fw-normal">Please sign in</h1>
<div class="form-floating">
<input type="email" class="form-control" id="floatingInput" placeholder="name@example.com">
<label for="floatingInput">Email address</label>
</div>
<div class="form-floating">
<input type="password" class="form-control" id="floatingPassword" placeholder="Password">
<label for="floatingPassword">Password</label>
</div>
<div class="form-check text-start my-3">
<input class="form-check-input" type="checkbox" value="remember-me" id="flexCheckDefault">
<label class="form-check-label" for="flexCheckDefault">
Remember me
</label>
</div>
<button class="btn btn-primary w-100 py-2" type="submit">Sign in</button>
<p class="mt-5 mb-3 text-body-secondary">© 2017–2024</p>
</form>
</main>
<!-- Popper -->
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"
integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"
crossorigin="anonymous"></script>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js"
integrity="sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy"
crossorigin="anonymous"></script>
</body>
</html>
LoginController.java
package com.woohahaapps.study.diary.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class LoginController {
@GetMapping("/login")
public String login(Model model) {
return "login";
}
}
프로그램을 실행시켰는데, 아직도 http://localhost:8080/login 페이지는 표시가 되질 않네.
이유는 해당 URL 에 대해서도 인증필요 상태이기 때문이야.
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.permitAll()
)
.build();
}
permitAll() 을 추가해주어야만 login 경로에 대해서 인증되지 않은 상태에서 접근이 가능해지지.
이제 사용자가 Email address 와 Password 를 입력하고 Sign in 버튼을 클릭했을 때 입력받은 데이터를 가지고 로그인 가능 여부를 판단하는 로직을 구현해볼께.
login.html 파일의 form 태그에 입력데이터를 처리할 url 을 명시해줄께.
login.html
<main class="form-signin w-100 m-auto">
<form th:action="@{/process_login}" method="POST">
<h1 class="h3 mb-3 fw-normal">Please sign in</h1>
<div class="form-floating">
<input type="email" class="form-control" id="floatingInput" name="email" placeholder="name@example.com">
<label for="floatingInput">Email address</label>
</div>
<div class="form-floating">
<input type="password" class="form-control" id="floatingPassword" name="password" placeholder="Password">
<label for="floatingPassword">Password</label>
</div>
<div class="form-check text-start my-3">
<input class="form-check-input" type="checkbox" value="remember-me" id="flexCheckDefault">
<label class="form-check-label" for="flexCheckDefault">
Remember me
</label>
</div>
<button class="btn btn-primary w-100 py-2" type="submit">Sign in</button>
<p class="mt-5 mb-3 text-body-secondary">© 2017–2024</p>
</form>
</main>
Sign in 버튼을 클릭했을 때 데이터를 처리하기 위해서 호출하는 action url 을 @{/process_login} 으로 설정하고 method 는 POST 를 설정했어.
그리고 email 주소와 password 태그에 name 속성을 지정했어.
이제 Sign in 버튼을 클릭했을 때 데이터를 처리할 /process_login URL 핸들러를 만들어야 하는데, 컨트롤러 클래스에 Post Action 에 대한 핸들러를 추가하는 것이 아니라, UserDetailsService 인터페이스에 대한 구현 클래스를 추가하는 방법이어야 해.
service 패키지 아래에 LoginService 라는 이름의 클래스를 만들고 UserDetailsService 인터페이스의 구현 클래스로 선언해보자.
LoginService.java
package com.woohahaapps.study.diary.service;
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;
@Service
public class LoginService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return null;
}
}
loadUserByUsername 함수는 UserDetailsService 인터페이스의 메소드를 오버라이드한 메소드야.
이 함수가 파라미터로 받는 username 이 로그인폼에서 전달한 값이 되는거야.
SecurityConfig 클래스의 속성 설정에 /process_login url 에 대한 속성을 추가해볼께.
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/process_login").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.loginProcessingUrl("/process_login")
.permitAll()
)
.build();
}
loginProcessingUrl 함수를 추가하고 파라미터로 로그인폼이 실행될 url 을 기록했어.
만약 위쪽의 requestMatchers(“/process_login”).permitAll() 을 추가하지 않는다면, anyRequest().authenticated() 에 의해서 /process_login URL 역시 인증된 상태에서만 호출되도록 설정되기 때문에 또다시 /login 페이지로 이동되겠지.
LoginService 클래스의 loadUserByUsername 함수에 BreakPoint 를 걸어두고 디버그모드로 실행해보면 파라미터 username 에 아무값도 들어오지 않는걸 확인할 수 있어.
로그인폼에서 이메일주소 입력박스의 name 이 email 이기 때문에 이 값이 제대로 전달되지 않은거니까, 이걸 수정해볼께.
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/process_login").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.loginProcessingUrl("/process_login")
.usernameParameter("email")
.permitAll()
)
.build();
}
다시 디버그모드로 실행해보면 로그인폼의 이메일주소로 입력한 값이 전달되고 있는 걸 확인할 수가 있지.
이제 로그인에 필요한 로직을 임시로 구현해볼께.
loadUserByUsername 함수가 리턴하는 UserDetails 은 interface 로 정의가 되어 있는데, 로그인 처리에 필요한 적절한 클래스로 구현한 객체를 리턴할 수 있도록 구성해보자.
가장 먼저 데이터베이스에서 관리되는 사용자정보에 매핑되는 domain 클래스 Member 를 생성해볼께.
package com.woohahaapps.study.diary.domain;
import lombok.Data;
@Data
public class Member {
private String email;
private String password;
}
지금은 단순하게 이메일주소와 비밀번호만 저장하고 있어.
그리고 UserDetails 인터페이스를 구현한 User 클래스를 생성해볼께.
package com.woohahaapps.study.diary.domain;
import lombok.Data;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
@Data
public class User implements UserDetails {
private Member member;
public User(Member member) {
this.member = member;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
return member.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
UserDetails 인터페이스를 구현하고, 메소드를 오버라이드했어.
그리고 Member 클래스형 객체를 선언해서 생성자를 통해서 주입받도록 했지.
LoginService 의 loadUserByUsername 함수는 아래와 같이 임시적인 코드로 작성했어.
@Service
public class LoginService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if (username.equals("woohaha@gmail.com")) {
return new User(new Member("woohaha@gmail.com", "1111"));
}
return null;
}
}
전달받은 파라미터값이 woohaha@gmail.com 인 경우에만 정상적인 사용자데이터(로그인 성공)로 처리하고, 나머지는 비정상적인 데이터(로그인 실패)로 처리하는거지.
이 상태에서 프로그램을 빌드하고 실행시켜볼께.
로그인성공을 예상하는 woohaha@gmail.com 을 입력했는데, 아래와 같은 오류가 터지고 있지.
아직 비밀번호 처리에 대한 코드가 작성되지 않았기 때문이야.
SecurytyConfig 에 passwordEncoder 라는 메소드를 Bean 으로 작성해줄께.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
...
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
이제 로그인폼에 입력한 패스워드 값이 BCryptPasswordEncoder 에 의해서 암호화되고, 사용자정보에 포함된 패스워드값이 BCryptPasswordEncoder 에 의해서 암호화해제가 되도록 설정되었어.
이 상태에서 다시 프로그램을 빌드해서 실행해보면 아래와 같은 로그를 볼 수가 있어.
2024-03-02T17:22:51.830+09:00 WARN 5092 --- [nio-8080-exec-6] o.s.s.c.bcrypt.BCryptPasswordEncoder : Encoded password does not look like BCrypt
LoginService 의 loadUserByUsername 함수가 전달하고 있는 패스워드가 평문인 “1111” 이라서 이 값이 BCrypt 에 의해서 암호화된 것으로 보이지 않는다는 의미야.
평문인 “1111” 을 강제로 암호화해서 전달해볼께.
@Service
public class LoginService implements UserDetailsService {
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if (username.equals("woohaha@gmail.com")) {
return new User(new Member("woohaha@gmail.com", passwordEncoder.encode("1111")));
}
return null;
}
}
이제 프로그램을 실행시켜서 email 주소로 woohaha@gmail.co, Password 값으로 1111 을 입력하면 로그인이 성공 처리되고 home 화면이 표시가 될거야.
남은 작업
이제 남은 작업은 사용자 목록을 관리하기 위한 테이블을 만들고, LoginService 에서 해당 테이블에서 데이터를 추출해서 넘기는 로직의 코드를 구현하는 일이야.