SpringBoot Security

JWT 정리, 프론트 연동, role 권한, refresh token

pjh8838 2024. 12. 20. 17:59
반응형

 

https://wogud.tistory.com/186

로그인 기능 자세하게 적어놓음 ( 비밀번호 해싱 )

 

1단계: 기본 로그인 기능 및 JWT 발급

1.로그인 기능 작성,  JWT 생성 유틸리티 작성 (JwtUtil.java)

mapper.xml


mapper.java


service


 

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 인증 필터 추가. 
    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으로 테스트 

프로필 컨트롤러
SecurityConfig
POST로 토큰 발급

 

 

테스트이기 때문에 굳이 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) 기반 권한 관리

  1. Spring Security에서 URL별 권한 설정:
    • /admin/** → ROLE_ADMIN.
    • /user/** → ROLE_USER.
  2. 컨트롤러에 @PreAuthorize 또는 @Secured 사용.

5단계: Refresh Token (선택 사항)

  1. Access Token과 Refresh Token 발급.
  2. Refresh Token 저장 및 만료 처리.
  3. Refresh Token을 사용한 Access Token 갱신 API 작성.
728x90
반응형

'SpringBoot Security' 카테고리의 다른 글

JWT 기초  (0) 2024.12.20
CORS, 시큐리티 기본 설정  (0) 2024.12.17