Xây Dựng Video Platform: Authentication

Platform chạy rồi, nhưng ai cũng truy cập được mọi endpoint. Đến lúc khóa lại. Trong bài này, chúng ta sẽ build hệ thống authentication hoàn chỉnh — đăng ký email/password, JWT access token, refresh token rotation, Google và GitHub OAuth2 login, và Next.js frontend xử lý toàn bộ auth flow.
Đây là một trong những bài dài nhất trong series vì authentication chạm đến mọi layer: database schema, Spring Security configuration, REST endpoint, cookie handling và frontend state management. Làm đúng phần này, và mọi bài sau chỉ build lên trên.
Thời gian: 2–3 giờ
Điều kiện: Phase 1: Project Setup
Những gì chúng ta sẽ build trong bài này:
✅ User và OAuth account database schema
✅ Spring Security configuration với JWT filter
✅ Endpoint đăng ký và đăng nhập email/password
✅ Access token (15 phút) + refresh token (7 ngày) với rotation
✅ Google và GitHub OAuth2 social login
✅ Next.js auth context với automatic token refresh
✅ Protected route và role-based access
Authentication Architecture
Trước khi viết code, hãy hiểu toàn bộ auth flow:
Token Strategy
| Token | Storage | Lifetime | Mục đích |
|---|---|---|---|
| Access token | JavaScript memory (variable) | 15 phút | Authenticate API request |
| Refresh token | HTTP-only secure cookie | 7 ngày | Lấy access token mới |
Tại sao chia như vậy?
- Access token trong memory không bị đánh cắp bởi XSS attack (không dùng localStorage/sessionStorage)
- Refresh token trong HTTP-only cookie JavaScript hoàn toàn không đọc được
- Access token lifetime ngắn giới hạn thiệt hại nếu bị lộ
- Refresh token rotation: mỗi lần refresh phát hành refresh token mới và vô hiệu hóa cái cũ
Database Schema
Thêm Dependency
Thêm security và JWT dependency vào backend/build.gradle:
dependencies {
// ... dependency hiện có
// Security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
// Test
testImplementation 'org.springframework.security:spring-security-test'
}User Entity
backend/src/main/java/dev/chanhle/courses/auth/entity/User.java:
package dev.chanhle.courses.auth.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "users")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
private String passwordHash;
@Column(nullable = false)
private String name;
private String avatarUrl;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Builder.Default
private Role role = Role.USER;
@Builder.Default
private boolean emailVerified = false;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<OAuthAccount> oauthAccounts = new ArrayList<>();
@CreationTimestamp
private Instant createdAt;
@UpdateTimestamp
private Instant updatedAt;
public enum Role {
USER, ADMIN
}
}OAuth Account Entity
backend/src/main/java/dev/chanhle/courses/auth/entity/OAuthAccount.java:
package dev.chanhle.courses.auth.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.Instant;
@Entity
@Table(name = "oauth_accounts",
uniqueConstraints = @UniqueConstraint(
columns = {"provider", "providerAccountId"}
))
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OAuthAccount {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(nullable = false)
private String provider;
@Column(nullable = false)
private String providerAccountId;
private String accessToken;
private String refreshToken;
@CreationTimestamp
private Instant createdAt;
}User Repository
backend/src/main/java/dev/chanhle/courses/auth/repository/UserRepository.java:
package dev.chanhle.courses.auth.repository;
import dev.chanhle.courses.auth.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
}OAuth Account Repository
backend/src/main/java/dev/chanhle/courses/auth/repository/OAuthAccountRepository.java:
package dev.chanhle.courses.auth.repository;
import dev.chanhle.courses.auth.entity.OAuthAccount;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface OAuthAccountRepository extends JpaRepository<OAuthAccount, Long> {
Optional<OAuthAccount> findByProviderAndProviderAccountId(
String provider, String providerAccountId);
}JWT Service
JWT service xử lý việc tạo và validate token. Nó dùng thư viện jjwt để ký token bằng HMAC-SHA256.
backend/src/main/java/dev/chanhle/courses/auth/service/JwtService.java:
package dev.chanhle.courses.auth.service;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
@Service
public class JwtService {
private final SecretKey signingKey;
private final Duration accessTokenExpiry;
public JwtService(
@Value("${app.jwt.secret}") String secret,
@Value("${app.jwt.access-token-expiry:15m}") Duration accessTokenExpiry) {
this.signingKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.accessTokenExpiry = accessTokenExpiry;
}
public String generateAccessToken(Long userId, String email, String role) {
Instant now = Instant.now();
return Jwts.builder()
.subject(userId.toString())
.claim("email", email)
.claim("role", role)
.issuedAt(Date.from(now))
.expiration(Date.from(now.plus(accessTokenExpiry)))
.signWith(signingKey)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(signingKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
public boolean isTokenValid(String token) {
try {
parseToken(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
public Long getUserId(String token) {
return Long.parseLong(parseToken(token).getSubject());
}
public String getEmail(String token) {
return parseToken(token).get("email", String.class);
}
public String getRole(String token) {
return parseToken(token).get("role", String.class);
}
}Thêm JWT configuration vào application.yml:
app:
jwt:
secret: ${JWT_SECRET:your-256-bit-secret-key-change-this-in-production-min-32-chars}
access-token-expiry: 15m
refresh-token-expiry: 7dQuan trọng: JWT secret phải ít nhất 32 ký tự cho HMAC-SHA256. Ở production, dùng random string lưu trong environment variable.
Refresh Token Service
Refresh token được lưu trong Redis cho lookup nhanh và dễ dàng vô hiệu hóa. Mỗi refresh token là một UUID — không cần parse JWT.
backend/src/main/java/dev/chanhle/courses/auth/service/RefreshTokenService.java:
package dev.chanhle.courses.auth.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.UUID;
@Service
public class RefreshTokenService {
private static final String PREFIX = "refresh_token:";
private final StringRedisTemplate redisTemplate;
private final Duration refreshTokenExpiry;
public RefreshTokenService(
StringRedisTemplate redisTemplate,
@Value("${app.jwt.refresh-token-expiry:7d}") Duration refreshTokenExpiry) {
this.redisTemplate = redisTemplate;
this.refreshTokenExpiry = refreshTokenExpiry;
}
public String createRefreshToken(Long userId) {
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(
PREFIX + token,
userId.toString(),
refreshTokenExpiry
);
return token;
}
public Long validateRefreshToken(String token) {
String userId = redisTemplate.opsForValue().get(PREFIX + token);
if (userId == null) {
return null;
}
return Long.parseLong(userId);
}
public void revokeRefreshToken(String token) {
redisTemplate.delete(PREFIX + token);
}
public String rotateRefreshToken(String oldToken) {
Long userId = validateRefreshToken(oldToken);
if (userId == null) {
return null;
}
revokeRefreshToken(oldToken);
return createRefreshToken(userId);
}
}Refresh token rotation: Khi client dùng refresh token để lấy access token mới, chúng ta:
- Validate refresh token cũ
- Xóa nó khỏi Redis (chỉ dùng một lần)
- Tạo refresh token mới
- Trả cả hai token mới
Điều này giới hạn thiệt hại nếu refresh token bị đánh cắp — attacker chỉ dùng được một lần trước khi nó bị vô hiệu hóa.
Spring Security Configuration
JWT Authentication Filter
Filter này chạy trên mỗi request, trích xuất JWT từ header Authorization, validate nó, và set authenticated user vào security context.
backend/src/main/java/dev/chanhle/courses/config/JwtAuthenticationFilter.java:
package dev.chanhle.courses.config;
import dev.chanhle.courses.auth.service.JwtService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
if (jwtService.isTokenValid(token)) {
Long userId = jwtService.getUserId(token);
String role = jwtService.getRole(token);
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userId,
null,
List.of(new SimpleGrantedAuthority("ROLE_" + role))
);
SecurityContextHolder.getContext().setAuthentication(authToken);
}
filterChain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath();
return path.startsWith("/api/auth/") || path.equals("/api/health");
}
}Các quyết định design quan trọng:
shouldNotFilter— auth endpoint và health check bỏ qua JWT validation (chúng là public)principal = userId— lưu user ID làm principal, dễ lấy trong controller:(Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal()- Role prefix — Spring Security yêu cầu role có prefix
ROLE_cho checkhasRole()
Security Configuration
backend/src/main/java/dev/chanhle/courses/config/SecurityConfig.java:
package dev.chanhle.courses.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
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;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// Public endpoint
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/health").permitAll()
.requestMatchers(HttpMethod.GET, "/api/courses/**").permitAll()
// Admin endpoint
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// Mọi thứ khác yêu cầu authentication
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}Các quyết định quan trọng:
- CSRF disabled — dùng JWT token, không phải session cookie, nên không cần CSRF protection
- Stateless session — không có server-side session; mỗi request mang JWT riêng
- Public endpoint — auth, health và course listing (GET) truy cập được không cần login
- Admin gating —
/api/admin/**yêu cầu roleADMIN - Thứ tự filter — JWT filter chạy trước default username/password filter của Spring
Auth DTO
Request và response object cho auth endpoint:
backend/src/main/java/dev/chanhle/courses/auth/dto/RegisterRequest.java:
package dev.chanhle.courses.auth.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class RegisterRequest {
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100)
private String name;
@NotBlank(message = "Email is required")
@Email(message = "Invalid email format")
private String email;
@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
private String password;
}backend/src/main/java/dev/chanhle/courses/auth/dto/LoginRequest.java:
package dev.chanhle.courses.auth.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class LoginRequest {
@NotBlank(message = "Email is required")
@Email(message = "Invalid email format")
private String email;
@NotBlank(message = "Password is required")
private String password;
}backend/src/main/java/dev/chanhle/courses/auth/dto/AuthResponse.java:
package dev.chanhle.courses.auth.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthResponse {
private String accessToken;
private UserDto user;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class UserDto {
private Long id;
private String email;
private String name;
private String avatarUrl;
private String role;
}
}Lưu ý: refresh token KHÔNG có trong response body. Nó được set là HTTP-only cookie — JavaScript frontend không bao giờ thấy nó.
Auth Service
Service layer xử lý đăng ký, đăng nhập và token management:
backend/src/main/java/dev/chanhle/courses/auth/service/AuthService.java:
package dev.chanhle.courses.auth.service;
import dev.chanhle.courses.auth.dto.AuthResponse;
import dev.chanhle.courses.auth.dto.LoginRequest;
import dev.chanhle.courses.auth.dto.RegisterRequest;
import dev.chanhle.courses.auth.entity.User;
import dev.chanhle.courses.auth.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final RefreshTokenService refreshTokenService;
@Transactional
public AuthResult register(RegisterRequest request) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new IllegalArgumentException("Email already registered");
}
User user = User.builder()
.email(request.getEmail())
.passwordHash(passwordEncoder.encode(request.getPassword()))
.name(request.getName())
.role(User.Role.USER)
.emailVerified(false)
.build();
user = userRepository.save(user);
return generateAuthResult(user);
}
public AuthResult login(LoginRequest request) {
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new IllegalArgumentException("Invalid email or password"));
if (user.getPasswordHash() == null) {
throw new IllegalArgumentException(
"This account uses social login. Please sign in with Google or GitHub.");
}
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
throw new IllegalArgumentException("Invalid email or password");
}
return generateAuthResult(user);
}
public AuthResult refresh(String refreshToken) {
Long userId = refreshTokenService.validateRefreshToken(refreshToken);
if (userId == null) {
throw new IllegalArgumentException("Invalid or expired refresh token");
}
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
// Rotate: thu hồi cũ, tạo mới
String newRefreshToken = refreshTokenService.rotateRefreshToken(refreshToken);
String accessToken = jwtService.generateAccessToken(
user.getId(), user.getEmail(), user.getRole().name());
AuthResponse response = buildAuthResponse(user, accessToken);
return new AuthResult(response, newRefreshToken);
}
public void logout(String refreshToken) {
if (refreshToken != null) {
refreshTokenService.revokeRefreshToken(refreshToken);
}
}
public Optional<AuthResponse.UserDto> getCurrentUser(Long userId) {
return userRepository.findById(userId)
.map(user -> AuthResponse.UserDto.builder()
.id(user.getId())
.email(user.getEmail())
.name(user.getName())
.avatarUrl(user.getAvatarUrl())
.role(user.getRole().name())
.build());
}
private AuthResult generateAuthResult(User user) {
String accessToken = jwtService.generateAccessToken(
user.getId(), user.getEmail(), user.getRole().name());
String refreshToken = refreshTokenService.createRefreshToken(user.getId());
AuthResponse response = buildAuthResponse(user, accessToken);
return new AuthResult(response, refreshToken);
}
private AuthResponse buildAuthResponse(User user, String accessToken) {
return AuthResponse.builder()
.accessToken(accessToken)
.user(AuthResponse.UserDto.builder()
.id(user.getId())
.email(user.getEmail())
.name(user.getName())
.avatarUrl(user.getAvatarUrl())
.role(user.getRole().name())
.build())
.build();
}
public record AuthResult(AuthResponse response, String refreshToken) {}
}Các pattern quan trọng:
- Cùng error message cho email/password — "Invalid email or password" cho cả sai email lẫn sai password. Ngăn attacker phát hiện email nào đã đăng ký.
- Phát hiện social login — nếu user đăng ký qua Google/GitHub (không có password hash), thông báo dùng social login thay vì thử password.
AuthResultrecord — gói API response và refresh token lại. Controller tách ra — response vào body, refresh token vào cookie.
Auth Controller
backend/src/main/java/dev/chanhle/courses/auth/controller/AuthController.java:
package dev.chanhle.courses.auth.controller;
import dev.chanhle.courses.auth.dto.AuthResponse;
import dev.chanhle.courses.auth.dto.LoginRequest;
import dev.chanhle.courses.auth.dto.RegisterRequest;
import dev.chanhle.courses.auth.service.AuthService;
import dev.chanhle.courses.common.dto.ApiResponse;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@Value("${app.jwt.refresh-token-expiry:7d}")
private java.time.Duration refreshTokenExpiry;
@PostMapping("/register")
public ResponseEntity<ApiResponse<AuthResponse>> register(
@Valid @RequestBody RegisterRequest request,
HttpServletResponse response) {
AuthService.AuthResult result = authService.register(request);
setRefreshTokenCookie(response, result.refreshToken());
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success("Registration successful", result.response()));
}
@PostMapping("/login")
public ResponseEntity<ApiResponse<AuthResponse>> login(
@Valid @RequestBody LoginRequest request,
HttpServletResponse response) {
AuthService.AuthResult result = authService.login(request);
setRefreshTokenCookie(response, result.refreshToken());
return ResponseEntity.ok(ApiResponse.success(result.response()));
}
@PostMapping("/refresh")
public ResponseEntity<ApiResponse<AuthResponse>> refresh(
@CookieValue(name = "refresh_token", required = false) String refreshToken,
HttpServletResponse response) {
if (refreshToken == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.error("No refresh token provided"));
}
AuthService.AuthResult result = authService.refresh(refreshToken);
setRefreshTokenCookie(response, result.refreshToken());
return ResponseEntity.ok(ApiResponse.success(result.response()));
}
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Void>> logout(
@CookieValue(name = "refresh_token", required = false) String refreshToken,
HttpServletResponse response) {
authService.logout(refreshToken);
clearRefreshTokenCookie(response);
return ResponseEntity.ok(ApiResponse.success("Logged out successfully", null));
}
@GetMapping("/me")
public ResponseEntity<ApiResponse<AuthResponse.UserDto>> me() {
Long userId = getCurrentUserId();
return authService.getCurrentUser(userId)
.map(user -> ResponseEntity.ok(ApiResponse.success(user)))
.orElse(ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("User not found")));
}
private void setRefreshTokenCookie(HttpServletResponse response, String token) {
Cookie cookie = new Cookie("refresh_token", token);
cookie.setHttpOnly(true);
cookie.setSecure(false); // Set true ở production (HTTPS)
cookie.setPath("/api/auth");
cookie.setMaxAge((int) refreshTokenExpiry.toSeconds());
cookie.setAttribute("SameSite", "Lax");
response.addCookie(cookie);
}
private void clearRefreshTokenCookie(HttpServletResponse response) {
Cookie cookie = new Cookie("refresh_token", "");
cookie.setHttpOnly(true);
cookie.setSecure(false);
cookie.setPath("/api/auth");
cookie.setMaxAge(0);
response.addCookie(cookie);
}
private Long getCurrentUserId() {
return (Long) org.springframework.security.core.context.SecurityContextHolder
.getContext().getAuthentication().getPrincipal();
}
}Giải thích cấu hình cookie:
httpOnly: true— JavaScript không đọc được cookie (bảo vệ XSS)secure: false— settrueở production (yêu cầu HTTPS)path: /api/auth— cookie chỉ gửi đến auth endpoint (không phải mọi API call)SameSite: Lax— cookie gửi trên same-site request và top-level navigation (bảo vệ CSRF)maxAge— khớp với refresh token expiry (7 ngày)
OAuth2 Social Login
Cách OAuth2 Hoạt Động
Cấu Hình OAuth2
Thêm OAuth2 client configuration vào application-dev.yml:
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: openid, profile, email
redirect-uri: "{baseUrl}/api/auth/oauth2/callback/{registrationId}"
github:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
scope: read:user, user:email
redirect-uri: "{baseUrl}/api/auth/oauth2/callback/{registrationId}"Lấy OAuth2 credential:
- Google: Vào Google Cloud Console → APIs & Services → Credentials → Create OAuth 2.0 Client ID
- GitHub: Vào GitHub Settings → OAuth Apps → New OAuth App
Set callback URL là http://localhost:8080/api/auth/oauth2/callback/google (và /github).
OAuth2 Service
Khi Google/GitHub trả về profile user, service này tạo hoặc liên kết account và generate token:
backend/src/main/java/dev/chanhle/courses/auth/service/OAuth2AuthService.java:
package dev.chanhle.courses.auth.service;
import dev.chanhle.courses.auth.entity.OAuthAccount;
import dev.chanhle.courses.auth.entity.User;
import dev.chanhle.courses.auth.repository.OAuthAccountRepository;
import dev.chanhle.courses.auth.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class OAuth2AuthService {
private final UserRepository userRepository;
private final OAuthAccountRepository oauthAccountRepository;
private final JwtService jwtService;
private final RefreshTokenService refreshTokenService;
@Transactional
public AuthService.AuthResult processOAuth2Login(
String provider, String providerAccountId,
String email, String name, String avatarUrl) {
// Kiểm tra OAuth account đã tồn tại chưa
var existingOAuth = oauthAccountRepository
.findByProviderAndProviderAccountId(provider, providerAccountId);
User user;
if (existingOAuth.isPresent()) {
// User OAuth đã có — chỉ cần login
user = existingOAuth.get().getUser();
} else {
// Kiểm tra user với email này đã tồn tại chưa
var existingUser = userRepository.findByEmail(email);
if (existingUser.isPresent()) {
// Liên kết OAuth account vào user hiện có
user = existingUser.get();
} else {
// Tạo user mới
user = User.builder()
.email(email)
.name(name)
.avatarUrl(avatarUrl)
.role(User.Role.USER)
.emailVerified(true) // OAuth email đã verified
.build();
user = userRepository.save(user);
}
// Tạo liên kết OAuth account
OAuthAccount oauthAccount = OAuthAccount.builder()
.user(user)
.provider(provider)
.providerAccountId(providerAccountId)
.build();
oauthAccountRepository.save(oauthAccount);
}
String accessToken = jwtService.generateAccessToken(
user.getId(), user.getEmail(), user.getRole().name());
String refreshToken = refreshTokenService.createRefreshToken(user.getId());
var response = dev.chanhle.courses.auth.dto.AuthResponse.builder()
.accessToken(accessToken)
.user(dev.chanhle.courses.auth.dto.AuthResponse.UserDto.builder()
.id(user.getId())
.email(user.getEmail())
.name(user.getName())
.avatarUrl(user.getAvatarUrl())
.role(user.getRole().name())
.build())
.build();
return new AuthService.AuthResult(response, refreshToken);
}
public Map<String, String> extractUserInfo(String provider,
Map<String, Object> attributes) {
return switch (provider) {
case "google" -> Map.of(
"id", attributes.get("sub").toString(),
"email", attributes.get("email").toString(),
"name", attributes.get("name").toString(),
"avatar", attributes.getOrDefault("picture", "").toString()
);
case "github" -> Map.of(
"id", attributes.get("id").toString(),
"email", attributes.getOrDefault("email", "").toString(),
"name", attributes.getOrDefault("name",
attributes.get("login")).toString(),
"avatar", attributes.getOrDefault("avatar_url", "").toString()
);
default -> throw new IllegalArgumentException("Unsupported provider: " + provider);
};
}
}OAuth2 Success Handler
backend/src/main/java/dev/chanhle/courses/config/OAuth2SuccessHandler.java:
package dev.chanhle.courses.config;
import dev.chanhle.courses.auth.service.AuthService;
import dev.chanhle.courses.auth.service.OAuth2AuthService;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.time.Duration;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final OAuth2AuthService oAuth2AuthService;
@Value("${app.frontend-url:http://localhost:3000}")
private String frontendUrl;
@Value("${app.jwt.refresh-token-expiry:7d}")
private Duration refreshTokenExpiry;
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication;
OAuth2User oAuth2User = authToken.getPrincipal();
String provider = authToken.getAuthorizedClientRegistrationId();
Map<String, String> userInfo = oAuth2AuthService.extractUserInfo(
provider, oAuth2User.getAttributes());
AuthService.AuthResult result = oAuth2AuthService.processOAuth2Login(
provider,
userInfo.get("id"),
userInfo.get("email"),
userInfo.get("name"),
userInfo.get("avatar")
);
// Set refresh token là HTTP-only cookie
Cookie cookie = new Cookie("refresh_token", result.refreshToken());
cookie.setHttpOnly(true);
cookie.setSecure(false); // true ở production
cookie.setPath("/api/auth");
cookie.setMaxAge((int) refreshTokenExpiry.toSeconds());
cookie.setAttribute("SameSite", "Lax");
response.addCookie(cookie);
// Redirect về frontend với access token trong URL fragment
String redirectUrl = frontendUrl + "/auth/callback#token="
+ result.response().getAccessToken();
response.sendRedirect(redirectUrl);
}
}Update SecurityConfig để thêm OAuth2 login support:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/health").permitAll()
.requestMatchers(HttpMethod.GET, "/api/courses/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(endpoint ->
endpoint.baseUri("/api/auth/oauth2"))
.redirectionEndpoint(endpoint ->
endpoint.baseUri("/api/auth/oauth2/callback/*"))
.successHandler(oAuth2SuccessHandler)
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}Thêm frontendUrl vào application.yml:
app:
frontend-url: ${FRONTEND_URL:http://localhost:3000}Next.js Auth Context
Auth Provider
Auth context quản lý access token, user state và logic token refresh:
frontend/lib/auth.tsx:
'use client';
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
ReactNode,
} from 'react';
import api from './api';
interface User {
id: number;
email: string;
name: string;
avatarUrl: string | null;
role: string;
}
interface AuthContextType {
user: User | null;
accessToken: string | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (name: string, email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
setAuth: (token: string, user: User) => void;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Thử refresh token khi load lần đầu
useEffect(() => {
refreshAuth();
}, []);
const refreshAuth = useCallback(async () => {
try {
const data: any = await api.post('/auth/refresh');
setAccessToken(data.data.accessToken);
setUser(data.data.user);
} catch {
setAccessToken(null);
setUser(null);
} finally {
setIsLoading(false);
}
}, []);
const login = useCallback(async (email: string, password: string) => {
const data: any = await api.post('/auth/login', { email, password });
setAccessToken(data.data.accessToken);
setUser(data.data.user);
}, []);
const register = useCallback(
async (name: string, email: string, password: string) => {
const data: any = await api.post('/auth/register', {
name,
email,
password,
});
setAccessToken(data.data.accessToken);
setUser(data.data.user);
},
[]
);
const logout = useCallback(async () => {
try {
await api.post('/auth/logout');
} finally {
setAccessToken(null);
setUser(null);
}
}, []);
const setAuth = useCallback((token: string, userData: User) => {
setAccessToken(token);
setUser(userData);
}, []);
return (
<AuthContext.Provider
value={{ user, accessToken, isLoading, login, register, logout, setAuth }}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}Axios Token Interceptor
Update frontend/lib/api.ts để đính kèm access token và xử lý automatic refresh:
import axios from 'axios';
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api';
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
withCredentials: true,
});
// Quản lý token — set bởi AuthProvider
let accessToken: string | null = null;
let refreshPromise: Promise<string | null> | null = null;
export function setAccessToken(token: string | null) {
accessToken = token;
}
export function getAccessToken(): string | null {
return accessToken;
}
// Request interceptor — đính kèm access token
api.interceptors.request.use((config) => {
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
// Response interceptor — xử lý 401 với automatic refresh
api.interceptors.response.use(
(response) => response.data,
async (error) => {
const originalRequest = error.config;
// Nếu 401 và chưa retry, thử refresh
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// Deduplicate concurrent refresh request
if (!refreshPromise) {
refreshPromise = refreshAccessToken();
}
const newToken = await refreshPromise;
refreshPromise = null;
if (newToken) {
accessToken = newToken;
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return api(originalRequest);
}
} catch {
refreshPromise = null;
}
}
const message =
error.response?.data?.message || 'An unexpected error occurred';
return Promise.reject(new Error(message));
}
);
async function refreshAccessToken(): Promise<string | null> {
try {
const response = await axios.post(
`${API_BASE_URL}/auth/refresh`,
{},
{ withCredentials: true }
);
return response.data?.data?.accessToken || null;
} catch {
return null;
}
}
export default api;Các pattern quan trọng:
refreshPromisededuplication — nếu nhiều API call nhận 401 cùng lúc, chỉ một refresh request được gửi. Tất cả call đang chờ đợi cùng một promise._retryflag — ngăn infinite retry loop nếu refresh thất bại.- Raw
axios.postcho refresh — dùng axios instance mới để tránh trigger interceptor đệ quy.
OAuth2 Callback Page
Trang này xử lý redirect từ Google/GitHub OAuth2 login:
frontend/app/auth/callback/page.tsx:
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
import { setAccessToken } from '@/lib/api';
export default function AuthCallback() {
const router = useRouter();
const { setAuth } = useAuth();
useEffect(() => {
// Trích xuất token từ URL fragment (#token=...)
const hash = window.location.hash;
const token = new URLSearchParams(hash.substring(1)).get('token');
if (token) {
setAccessToken(token);
// Fetch user profile với token mới
fetch(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api'}/auth/me`,
{
headers: { Authorization: `Bearer ${token}` },
}
)
.then((res) => res.json())
.then((data) => {
if (data.success) {
setAuth(token, data.data);
router.push('/');
}
})
.catch(() => {
router.push('/login?error=oauth_failed');
});
} else {
router.push('/login?error=no_token');
}
}, [router, setAuth]);
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4" />
<p className="text-gray-600">Completing sign in...</p>
</div>
</div>
);
}Protected Route Component
frontend/components/ProtectedRoute.tsx:
'use client';
import { useAuth } from '@/lib/auth';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
interface ProtectedRouteProps {
children: React.ReactNode;
requiredRole?: string;
}
export default function ProtectedRoute({
children,
requiredRole,
}: ProtectedRouteProps) {
const { user, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !user) {
router.push('/login');
}
if (!isLoading && user && requiredRole && user.role !== requiredRole) {
router.push('/');
}
}, [user, isLoading, requiredRole, router]);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
);
}
if (!user) return null;
if (requiredRole && user.role !== requiredRole) return null;
return <>{children}</>;
}Cách dùng:
// Mọi user đã authenticate
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
// Chỉ admin
<ProtectedRoute requiredRole="ADMIN">
<AdminDashboard />
</ProtectedRoute>Login Page
frontend/app/login/page.tsx:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await login(email, password);
router.push('/');
} catch (err: any) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
return (
<div className="max-w-md mx-auto mt-20">
<h1 className="text-2xl font-bold text-center mb-8">Sign In</h1>
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded mb-4">{error}</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition"
>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-gray-50 text-gray-500">Hoặc tiếp tục với</span>
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<a
href={`${API_URL.replace('/api', '')}/api/auth/oauth2/google`}
className="flex items-center justify-center px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition"
>
<span className="text-sm font-medium text-gray-700">Google</span>
</a>
<a
href={`${API_URL.replace('/api', '')}/api/auth/oauth2/github`}
className="flex items-center justify-center px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition"
>
<span className="text-sm font-medium text-gray-700">GitHub</span>
</a>
</div>
</div>
<p className="text-center text-sm text-gray-600 mt-6">
Chưa có tài khoản?{' '}
<a href="/register" className="text-blue-600 hover:underline">
Đăng ký
</a>
</p>
</div>
);
}Auth Flow Hoàn Chỉnh
Đây là full request flow cho mỗi auth scenario:
Email/Password Login
Token Refresh (Tự Động)
Test Auth System
1. Đăng Ký User Mới
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"name": "John Doe",
"email": "[email protected]",
"password": "password123"
}' -vResponse body:
{
"success": true,
"message": "Registration successful",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"user": {
"id": 1,
"email": "[email protected]",
"name": "John Doe",
"role": "USER"
}
}
}Kiểm tra header Set-Cookie — bạn sẽ thấy refresh token:
Set-Cookie: refresh_token=550e8400-e29b-41d4-a716-446655440000; Path=/api/auth; HttpOnly; Max-Age=6048002. Đăng Nhập
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"password": "password123"
}' -c cookies.txt3. Truy Cập Protected Endpoint
# Với token hợp lệ — hoạt động
curl http://localhost:8080/api/auth/me \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."
# Không có token — 401
curl http://localhost:8080/api/auth/me4. Refresh Token
curl -X POST http://localhost:8080/api/auth/refresh -b cookies.txt -c cookies.txt5. OAuth2 Login
Mở trong browser: http://localhost:8080/api/auth/oauth2/google
Bạn sẽ được redirect đến Google consent screen, sau đó quay lại frontend.
Lỗi Thường Gặp
1. Refresh Token Cookie Không Được Gửi
Nếu refresh endpoint trả về "No refresh token provided":
- Kiểm tra
withCredentials: trueđược set trên cả Axios instance và refresh request - Xác nhận cookie
Pathkhớp với request URL (/api/auth) - Trong Chrome DevTools → Application → Cookies, kiểm tra cookie có tồn tại không
2. Lỗi CORS Khi OAuth2 Redirect
OAuth2 redirect là full page navigation (302), không phải AJAX request. Chúng không cần CORS. Nhưng nếu bạn đang gọi OAuth2 URL qua fetch() hay axios, hãy dùng link <a> thường hoặc window.location.href thay thế.
3. JWT Secret Quá Ngắn
Nếu bạn thấy WeakKeyException:
The signing key's size is 128 bits which is not secure enough for the HS256 algorithm.JWT_SECRET phải ít nhất 32 ký tự (256 bit). Tạo một cái:
openssl rand -base64 324. GitHub OAuth2 Email Là Null
GitHub không phải lúc nào cũng kèm email trong OAuth2 profile. Nếu email của user là private, bạn cần gọi thêm API /user/emails. Để đơn giản, bạn có thể yêu cầu user có public email, hoặc xử lý trường hợp null bằng cách yêu cầu họ nhập email sau lần OAuth2 login đầu tiên.
Tiếp Theo Là Gì?
Authentication hoàn tất. User có thể đăng ký, đăng nhập bằng email hoặc social account, và frontend xử lý token refresh tự động. Trong Bài #4, chúng ta sẽ build course data model:
- JPA entity cho course, section và lesson
- Flyway database migration
- Admin CRUD API với validation
- Slug generation và sort ordering
- DTO mapping với MapStruct
Đến lúc bắt đầu quản lý content.
Series: Xây Dựng Video Streaming Platform
Trước đó: Phase 1: Project Setup
Tiếp theo: Phase 3: Course & Lesson Data Model
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.