Building a Tech Blog from Scratch (Part 2): Implementing Login Authentication with Spring Security and JWT
This article walks through creating a complete login authentication system—including registration, login, token refresh, and permission control—by replacing Spring Security's default session handling with JWT, configuring backend filters and utilities, and integrating a Vue 3 + Pinia front‑end with automatic token renewal.
Overall Goal
Implement a full login authentication system for a technical blog, covering user registration, login, token refresh, and role‑based access control using Spring Security with JWT instead of the default session mechanism.
Core Authentication Flow
User submits username and password → backend validates → generates JWT Access Token.
Frontend includes Authorization: Bearer <token> in request headers.
Backend filter intercepts each request, parses the token, and sets authentication information.
When the Access Token expires, a Refresh Token is used to obtain a new Access Token.
JWT Utility Class
@Component
public class JwtUtils {
@Value("${jwt.secret}")
private String secret; // Base64‑encoded secret (256‑bit minimum)
@Value("${jwt.expiration}")
private Long expiration; // 1 hour (ms)
@Value("${jwt.refresh-expiration}")
private Long refreshExpiration; // 7 days (ms)
private Key key;
@PostConstruct
public void init() {
byte[] keyBytes = Base64.getDecoder().decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
/** Generate Access Token */
public String generateToken(Long userId, String username, String role) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("username", username)
.claim("role", role)
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
/** Generate Refresh Token (lightweight) */
public String generateRefreshToken(Long userId) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + refreshExpiration);
return Jwts.builder()
.setSubject(String.valueOf(userId))
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public Long getUserIdFromToken(String token) {
Claims claims = parseToken(token);
return Long.parseLong(claims.getSubject());
}
public String getUsernameFromToken(String token) {
Claims claims = parseToken(token);
return claims.get("username", String.class);
}
public String getRoleFromToken(String token) {
Claims claims = parseToken(token);
return claims.get("role", String.class);
}
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (ExpiredJwtException e) {
log.warn("Token已过期");
} catch (JwtException e) {
log.warn("Token无效");
}
return false;
}
public boolean isTokenExpired(String token) {
try {
Claims claims = parseToken(token);
return claims.getExpiration().before(new Date());
} catch (ExpiredJwtException e) {
return true;
}
}
private Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
}Configuration (application.yml)
jwt:
secret: YXNkZmdoamtsMTIzNDU2Nzg5MGFzZGZnaGprbDEyMzQ1Njc4OTBhc2RmZ2hqa2w=
expiration: 3600000 # 1 hour
refresh-expiration: 604800000 # 7 daysSpring Security Core Configuration
1. Custom UserDetailsService
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userMapper.selectByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("用户不存在");
}
if (user.getStatus() == 0) {
throw new UsernameNotFoundException("账号已被禁用");
}
return org.springframework.security.core.userdetails.User
.withUsername(username)
.password(user.getPassword())
.authorities(user.getRole()) // "user" or "admin"
.build();
}
}2. JWT Authentication Filter (core)
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtUtils jwtUtils;
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 1. Extract token from Header
String authHeader = request.getHeader("Authorization");
String token = null;
if (authHeader != null && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7);
}
// 2. Allow login/registration endpoints to pass through
if (token == null) {
chain.doFilter(request, response);
return;
}
// 3. Validate token
if (!jwtUtils.validateToken(token)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JSON.toJSONString(Result.error(401, "Token无效或已过期")));
return;
}
// 4. Set authentication context
String username = jwtUtils.getUsernameFromToken(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
// 5. Continue filter chain
chain.doFilter(request, response);
}
}3. Security Configuration Class
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // password hashing
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // stateless
.and()
.authorizeRequests()
.antMatchers("/api/auth/**", "/api/articles/public/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
.anyRequest().authenticated()
.and()
.exceptionHandling()
.authenticationEntryPoint((req, res, ex) -> {
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
res.setContentType("application/json;charset=UTF-8");
res.getWriter().write(JSON.toJSONString(Result.error(401, "请先登录")));
});
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}Custom Permission Annotations (optional)
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasAuthority(T(com.blog.common.enums.Role).ADMIN.getCode())")
public @interface RequireAdmin {}
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequirePermission {
String[] value(); // e.g., {"admin", "editor"}
}Frontend Implementation (Vue 3 + Pinia)
Login Page (Login.vue)
<template>
<div class="login-container">
<el-card class="login-card">
<h2>博客系统登录</h2>
<el-form :model="form" :rules="rules" ref="formRef">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleLogin" :loading="loading" style="width: 100%">登录</el-button>
</el-form-item>
</el-form>
<div class="register-link">还没有账号?<router-link to="/register">立即注册</router-link></div>
</el-card>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const formRef = ref()
const loading = ref(false)
const form = reactive({ username: '', password: '' })
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
const handleLogin = async () => {
await formRef.value.validate()
loading.value = true
try {
await userStore.login(form)
ElMessage.success('登录成功')
const redirect = route.query.redirect || '/'
router.push(redirect)
} catch (error) {
ElMessage.error(error.message || '登录失败')
} finally {
loading.value = false
}
}
</script>Pinia Store (user.js)
// stores/user.js
import { defineStore } from 'pinia'
import { login as loginApi, logout as logoutApi, refreshToken as refreshTokenApi } from '@/api/auth'
import { setToken, removeToken, getToken } from '@/utils/token'
export const useUserStore = defineStore('user', {
state: () => ({
userInfo: null,
token: getToken(),
refreshToken: null
}),
actions: {
async login(loginForm) {
const res = await loginApi(loginForm)
if (res.code === 200) {
this.token = res.data.accessToken
this.refreshToken = res.data.refreshToken
this.userInfo = {
id: res.data.userId,
username: res.data.username,
nickname: res.data.nickname,
avatar: res.data.avatar,
role: res.data.role
}
setToken(res.data.accessToken)
return res
}
throw new Error(res.msg)
},
async logout() {
await logoutApi()
this.token = null
this.refreshToken = null
this.userInfo = null
removeToken()
},
async refreshAccessToken() {
const res = await refreshTokenApi({ refreshToken: this.refreshToken })
if (res.code === 200) {
this.token = res.data.accessToken
this.refreshToken = res.data.refreshToken
setToken(res.data.accessToken)
return true
}
return false
}
}
})Axios Interceptor (request.js) – Automatic Token Refresh
// utils/request.js
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import router from '@/router'
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000
})
let isRefreshing = false
let pendingRequests = []
// Request interceptor – add token
request.interceptors.request.use(config => {
const token = useUserStore().token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Response interceptor – handle 401
request.interceptors.response.use(
response => response.data,
async error => {
const originalRequest = error.config
const { response } = error
if (response?.status === 401 && !originalRequest._retry) {
const userStore = useUserStore()
if (!userStore.refreshToken) {
await userStore.logout()
router.push('/login')
return Promise.reject(error)
}
if (isRefreshing) {
return new Promise((resolve, reject) => {
pendingRequests.push(() => {
request(originalRequest).then(resolve).catch(reject)
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
const success = await userStore.refreshAccessToken()
if (success) {
pendingRequests.forEach(cb => cb())
pendingRequests = []
return request(originalRequest)
}
} catch (err) {
await userStore.logout()
router.push('/login')
return Promise.reject(err)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
}
)
export default requestRouter Guard (router/index.js)
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const routes = [
{ path: '/', component: () => import('@/views/home/Home.vue') },
{ path: '/login', component: () => import('@/views/login/Login.vue') },
{ path: '/register', component: () => import('@/views/login/Register.vue') },
{
path: '/admin',
component: () => import('@/layout/AdminLayout.vue'),
meta: { requiresAuth: true, roles: ['admin'] },
children: [
{ path: 'users', component: () => import('@/views/admin/Users.vue') },
{ path: 'articles', component: () => import('@/views/admin/Articles.vue') }
]
}
]
const router = createRouter({ history: createWebHistory(), routes })
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
const token = userStore.token
if (to.path === '/login') {
if (token) return next('/')
return next()
}
if (to.meta.requiresAuth) {
if (!token) {
return next({ path: '/login', query: { redirect: to.fullPath } })
}
if (to.meta.roles && !to.meta.roles.includes(userStore.userInfo?.role)) {
return next('/403')
}
}
next()
})
export default routerAPI Endpoints and Testing
POST /api/auth/register – body: {"username":"zhangsan","password":"123456","nickname":"张三"}
POST /api/auth/login – body: {"username":"zhangsan","password":"123456"} – returns Access Token and Refresh Token.
GET /api/user/info – Header: Authorization: Bearer <accessToken> POST /api/auth/refresh – body: {"refreshToken":"..."} – obtains new tokens.
Next Episode Preview
The upcoming article will implement the article‑management features, including publishing with a Markdown editor, paginated article listing, category and tag management, and read‑count statistics using Redis deduplication.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Coder Trainee
Experienced in Java and Python, we share and learn together. For submissions or collaborations, DM us.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
