반응형
로그인 기능 자세하게 적어놓음 ( 비밀번호 해싱 )
1단계: 기본 로그인 기능 및 JWT 발급
1.로그인 기능 작성, JWT 생성 유틸리티 작성 (JwtUtil.java)
serviceImpl
@Service
public class AuthServiceImpl implements AuthService {
private final AuthMapper authMapper;
private final PasswordEncoder passwordEncoder;
@Autowired
public AuthServiceImpl(AuthMapper authMapper, PasswordEncoder passwordEncoder) {
this.authMapper = authMapper;
this.passwordEncoder = passwordEncoder;
}
//로그인 기능의 핵심 로직
@Override
public boolean authenticate(String username, String password) {
// DB에서 username으로 사용자 정보 조회
AuthDTO user = authMapper.findByUsername(username);
// 사용자 존재 여부 및 비밀번호 검증
if (user != null) {
// 입력된 비밀번호와 db에 저장된 해싱된 비밀번호 비교
return passwordEncoder.matches(password, user.getPassword());
}
return false; // 사용자 정보가 없거나 비밀번호 불일치
}
}
2. Jwt 생성 및 검증을 위한 유틸리티 클래스
// jwt 의존성
//jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
// 시크릿키 생성 ( 이거 실행하면 Console에 시크릿키 생성 )
public class SecretKeyGenerator {
public static void main(String[] args) {
SecureRandom secureRandom = new SecureRandom();
byte[] keyBytes = new byte[32]; // 32바이트 = 256 bits
secureRandom.nextBytes(keyBytes);
String secretkey = Base64.getEncoder().encodeToString(keyBytes);
System.out.println("key: " + secretkey);
}
}
// properties
# jwt 시크릿키
jwt.secret=
// JwtUtil.java
package com.example.demo.auth.util;
import java.security.Key;
import java.util.Date;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
@Component
public class JwtUtil {
@Value("${jwt.secret}") //프로퍼티즈에서 시크릿키 읽기
private String SECRET_KEY;
private static final long EXPIRATION_TIME = 1000 * 60 * 60; // 1시간 // 토큰 만료 시간
private Key getSigningKey() {
return Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET_KEY));
}
// JWT 생성
public String generateToken(String username, String role) {
return Jwts.builder()
.setSubject(username)
.claim("role", role)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
// JWT에서 사용자 이름 추출
public String extractUsername(String token) {
return extractClaims(token).getSubject();
}
// JWT에서 역할(Role) 추출
public String extractRole(String token) {
return (String) extractClaims(token).get("role");
}
// JWT 유효성 검증
public boolean isTokenValid(String token) {
try {
return !extractClaims(token).getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
// Claims 추출
private Claims extractClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
}
3. 로그인 요청 처리 DTO 생성
4. 로그인 요청 성공 시 JWT 발급, 토큰 반환 response
// 컨트롤러
package com.example.demo.auth.controller;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.example.demo.auth.model.AuthLoginRequestDTO;
import com.example.demo.auth.service.AuthService;
import com.example.demo.auth.util.JwtUtil;
import jakarta.validation.Valid;
@RestController
@CrossOrigin(origins = "http://localhost:8080")
@RequestMapping("/api/auth")
public class AuthController {
private AuthService authService;
private final JwtUtil jwtUtil;
public AuthController(AuthService authService, JwtUtil jwtUtil) {
this.authService = authService;
this.jwtUtil = jwtUtil;
}
// 로그인 처리
@PostMapping("/login")
public ResponseEntity<?> login(@Valid @RequestBody AuthLoginRequestDTO request) {
// ResponseEntity는 HTTP 응답 데이터 다루는 클래스
// <?>는 타입이 정해지지않았을때 아무 타입이란 의미
// @Valid 요청 데이터 검증
// @RequestBody는 클라이언트가 보낸 JSON 데이터를 Java 객체(AuthLoginRequestDTO)로 변환
// request는 변환된 데이터를 담고 있는 객체
// 서비스 호출: 비즈니스 로직 처리
// 입력한 사용자 정보가 유효한지 검증
boolean isAuthenticated = authService.authenticate(request.getUsername(), request.getPassword());
if (isAuthenticated) {
// 인증 성공 : JWT 토큰 생성
String token = jwtUtil.generateToken(request.getUsername(), "USER");
// 토큰 반환, Key-Value 쌍이 2개인 of 선택
return ResponseEntity.ok().body(Map.of("message", "로그인 성공!", "token", token));
} else {
// 인증 실패 : 상태코드 401 반환
return ResponseEntity.status(401).body(Map.of("message", "아이디 또는 비밀번호가 일치하지 않습니다."));
}
}
}
2단계: 요청마다 JWT 검증
1. JWT 인증 필터 작성 (JwtAuthenticationFilter.java):
- JWT를 검증하고 사용자 정보를 SecurityContext에 저장.
// JwtAuthenticationFilter
package com.example.demo.auth.util;
import java.io.IOException;
import java.util.ArrayList;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter{
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
super();
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String authorizationHeader = request.getHeader("Authorization");
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
String token = authorizationHeader.substring(7);
if (jwtUtil.isTokenValid(token)) {
String username = jwtUtil.extractUsername(token);
// SecurityContext 설정 (생략 가능)
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
username, null, new ArrayList<>());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response);
}
}
2. Spring Security 설정:
- SecurityConfig에 JWT 인증 필터 추가.
// SecurityConfig
package com.example.demo.config;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import com.example.demo.auth.util.JwtAuthenticationFilter;
@Configuration
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain securityFilterChain (HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // CSRF 보호 비활성화 (개발 시)
.cors(cors -> cors.configurationSource(corsConfigurationSource())) // CORS 설정
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login").permitAll() // /public 경로는 누구나 접근 가능
.requestMatchers(HttpMethod.POST, "/admin/**").hasRole("ADMIN") // /admin 경로는 ADMIN만 접근 가능
.anyRequest().authenticated() // 그 외는 인증 필요
);
// spring security 6.xxx 이상에서는 and() 없이 설정을 명확히 분리
http
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
// cors 규칙 정의
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:8080")); // 프론트엔드 주소 허용
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE")); // 허용할 HTTP 메서드
configuration.setAllowedHeaders(List.of("*")); // 모든 요청 헤더 허용
configuration.setAllowCredentials(true); // 자격 증명 허용 (쿠키, 인증 헤더 등)
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration); // 모든 경로에 대해 CORS 설정, CORS 설정 범위를 지정하는 것이지 허용하는거 아님
return source;
}
// BCryptPasswordEncoder를 Bean으로 등록
// 패스워드 입력하면 db 비밀번호랑 비교용 - serviceImpl에서
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
테스트
- 1) 로그인 요청
- 2) JWT 인증 요청
프로필 컨트롤러 하나 만들어서 경로 허용 후
Post로 jwt 받고
get으로 테스트
테스트이기 때문에 굳이 db값까지 확인할 필요 없고 테스트 값만 보면 된다.
3단계: 프론트엔드 연동 ( Vue3 )
vue3 문법에 맞춰서 composition API 방식으로 script를 작성,
api는 composables 폴더를 만들어서 따로 관리
// api / index.js
// api 설정파일
import axios from 'axios';
const apiClient = axios.create({
baseURL: 'http://localhost:8081/myapp', // SpringBoot 서버 URL
headers: {
'Content-Type': 'application/json',
},
timeout: 5000, // 요청 타임아웃 설정
});
// Axios 요청 인터셉터
apiClient.interceptors.request.use(
(config) => {
// 보안을 위해서 나중에 localStorage 대신 Cookie( HttpOnly )나 Refresh token 사용
const token = localStorage.getItem('jwt'); // JWT 토큰 가져오기
if (token && !config.url.includes('/api/auth/login')) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
export default apiClient;
// api / auth.js
// 로그인 API
import apiClient from "./index";
//사용자 로그인 API 함수
export const loginUser = async (username, password) => {
try {
const response = await apiClient.post('/api/auth/login', {
username,
password
}); //POST 요청
return response.data;
} catch (error) {
console.error('Error fetching users:', error.message);
throw error;
}
}
// composables / login.js
// 로그인 메서드
import { ref } from "vue";
import { loginUser } from "../api/auth";
export default function login() {
const username = ref('');
const password = ref('');
const jwtToken = ref('');
const error = ref(null); // 에러 상태
// 로그인 메서드
const loginM = async () => {
error.value = '';
try {
const response = await loginUser(username.value, password.value);
jwtToken.value = response.token;
localStorage.setItem('jwt', jwtToken.value); // JWT 저장
alert('로그인 성공!');
} catch (err) {
// 에러 객체 err
error.value = err.response?.data?.message || '로그인 실패';
}
};
// 컴포넌트가 마운트되면 API 호출
// 로그인 버튼을 클릭했을 때 호출 되어야 해서 뺴야됌
// onMounted(() => {
// loginM();
// });
return {
loginM,
username,
password,
jwtToken,
error
};
}
// LoginForm.vue
// 로그인 화면
<template>
<div class="testBox">
Test
</div>
<div class="loginForm">
<form @submit.prevent="handleLogin" class="form">
<!-- prevent는 폼 제출 시 새로고침 방지 -->
<input type="text" v-model="username" placeholder="UserName"/>
<input type="text" v-model="password" placeholder="PassWord"/>
<!-- 단순히 입력 값을 서버로 보내면 v-model 없어도 되지만
입력값 검증, 초기화 등이 필요하면 v-model이 유용
Vue3 권장사항 -->
<button type="submit" @click="login">Login</button>
<button type="submit">join</button>
<p v-if="error" class="error">{{ error }}</p>
</form>
</div>
</template>
<script setup>
// api, composable 사용
// import { ref } from 'vue';
import { onMounted } from 'vue';
import login from '@/composables/login';
import { useRouter } from 'vue-router';
const router = useRouter(); // Vue Router 인스턴스 생성
const { username, password, error, loginM } = login();
const handleLogin = async () => {
try {
//로그인 함수 호출
await loginM();
//JWT 저장 성공 시 홈화면으로 이동
if(!error.value) {
router.push('/home');
}
} catch (err) {
//err인 이유는 login.js에서 오류객체를 err로 받아왔기 때문
console.log("로그인 처리 중 오류:", err);
}
};
// 토큰 초기화 함수
const initializeTokens = () => {
localStorage.removeItem('jwt');
sessionStorage.removeItem('jwt');
document.cookie = "Authorization=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
};
onMounted(() => {
initializeTokens(); // 로그인 화면 로드 시 토큰 초기화
});
</script>
<style>
.testBox {
height: 200px;
display: flex;
justify-content: center;
align-items: center;
background-color: skyblue;
font-size: 3rem;
}
.loginForm {
display: flex;
justify-content: center;
align-items: center;
height: 100px;
}
</style>
LoginForm.vue에서 handleLogin 호출 → login.js의 loginM 호출 → auth.js의 loginUser 호출.
// router / index.js
// 라우터 requiresAuth 설정
// 라우터 네비게이션 가드 설정
import { createRouter, createWebHistory } from 'vue-router'
// 정적 import
// import HomePage from '@/views/HomePage.vue';
// import HelloWorld from '../components/HelloWorld.vue';
// import DefineProps from '../components/DefineProps.vue';
// import ApiTest from '@/components/ApiTest.vue';
// import LoginForm from '@/components/LoginForm.vue'
const routes = [
{
path: "/",
name: "LoginForm",
// 동적 import ( Lazy Loading ) ( 경로 접근시 컴포넌트 가져옴, 성능 최적화 )
// component: LoginForm,
component: () => import('@/components/LoginForm.vue'),
meta: { requiresAuth: false } // 비로그인시 접근 가능
},
{
path: "/home",
name: "HomePage",
component: () => import('@/views/HomePage.vue'),
meta: { requiresAuth: true } // 로그인해야 접근 가능
},
{
path: "/HelloWorld",
name: "HelloWorld",
component: () => import('@/components/HelloWorld.vue'),
meta: { requiresAuth: true }
},
{
path: "/de",
name: "DefineProps",
component: () => import('@/components/DefineProps.vue'),
meta: { requiresAuth: true }
},
{
path: "/api",
name: "ApiTest",
component: () => import('@/components/ApiTest.vue'),
meta: { requiresAuth: true }
},
];
const router = createRouter({
history: createWebHistory(),
routes,
})
// 네비게이션 가드
router.beforeEach((to, from, next) => {
const isAuthenticated = !!localStorage.getItem('jwt'); // JWT가 존재하면 인증됨
if (to.meta.requiresAuth && !isAuthenticated) {
// 인증이 필요한데 인증되지 않으면 로그인 페이지로 리디렉션
if (to.path !== '/') {
return next('/');
}
} else if (!to.meta.requiresAuth && isAuthenticated) {
// 이미 인증된 상태에서 로그인 페이지로 가려고 하면 홈으로 리디렉션
if (to.path === '/') {
return next('/home');
}
}
next(); // 다른 경우에는 그대로 진행
});
export default router;
서버에서 토큰 만료 시간을 1시간으로 정함.
로그아웃 안하고 하루가 지났는데 jwt가 남아있고 로그인 유지되는 문제 발견
=> 만료된 토큰 삭제, 로그인페이지로 리다이렉션 로직 추가
// router / index.js
import { jwtDecode } from 'jwt-decode';
// JWT 만료 확인 함수
const isTokenExpired = (token) => {
try {
const decoded = jwtDecode(token);
const currentTime = Date.now() / 1000; // 현재 시간 (초 단위)
return decoded.exp < currentTime; // 만료 시간 비교
} catch (error) {
return true; // 오류 발생 시 만료로 간주
}
};
// 네비게이션 가드
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('jwt'); // JWT 가져오기
const isAuthenticated = token && !isTokenExpired(token); // 토큰 존재 & 유효성 확인
if (to.meta.requiresAuth && !isAuthenticated) {
// 인증이 필요한데 인증되지 않은 경우
localStorage.removeItem('jwt'); // 만료된 토큰 삭제
return next('/'); // 로그인 페이지로 리디렉션
}
if (!to.meta.requiresAuth && isAuthenticated) {
// 로그인된 상태에서 비로그인 페이지로 접근하려는 경우
return next('/home'); // 홈으로 리디렉션
}
next(); // 조건에 해당하지 않으면 계속 진행
});
728x90
4단계: 역할(Role) 기반 권한 관리
- Spring Security에서 URL별 권한 설정:
- /admin/** → ROLE_ADMIN.
- /user/** → ROLE_USER.
- 컨트롤러에 @PreAuthorize 또는 @Secured 사용.
5단계: Refresh Token (선택 사항)
- Access Token과 Refresh Token 발급.
- Refresh Token 저장 및 만료 처리.
- Refresh Token을 사용한 Access Token 갱신 API 작성.
728x90
반응형
'SpringBoot Security' 카테고리의 다른 글
JWT 기초 (0) | 2024.12.20 |
---|---|
CORS, 시큐리티 기본 설정 (0) | 2024.12.17 |