Back to blog

Xây Dựng Video Platform: Authentication

javaspring-bootreactnextjsvideo-streaming
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

TokenStorageLifetimeMục đích
Access tokenJavaScript memory (variable)15 phútAuthenticate API request
Refresh tokenHTTP-only secure cookie7 ngàyLấ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: 7d

Quan 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:

  1. Validate refresh token cũ
  2. Xóa nó khỏi Redis (chỉ dùng một lần)
  3. Tạo refresh token mới
  4. 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 check hasRole()

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 role ADMIN
  • 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.
  • AuthResult record — 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 — set true ở 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:

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:

  • refreshPromise deduplication — 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.
  • _retry flag — ngăn infinite retry loop nếu refresh thất bại.
  • Raw axios.post cho 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"
  }' -v

Response 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=604800

2. Đă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.txt

3. 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/me

4. Refresh Token

curl -X POST http://localhost:8080/api/auth/refresh -b cookies.txt -c cookies.txt

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

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 Path khớ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 32

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