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.

Coder Trainee
Coder Trainee
Coder Trainee
Building a Tech Blog from Scratch (Part 2): Implementing Login Authentication with Spring Security and JWT

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 days

Spring 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 request

Router 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 router

API 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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

spring-bootauthenticationJWTpiniaVue3spring-security
Coder Trainee
Written by

Coder Trainee

Experienced in Java and Python, we share and learn together. For submissions or collaborations, DM us.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.