ЛАБ 10

Програм Хангамжийн Аюулгүй Байдал

3.1 Лабораторийн зорилго

Энэ лабораторид та:

  1. Spring Security ашиглан JWT authentication хэрэгжүүлэх
  2. Role-based authorization тохируулах
  3. Input validation нэмэх
  4. Password bcrypt-ээр хадгалах
  5. CORS, Security Headers тохируулах

Хэл: Java 17+ / Spring Boot 3.x | Framework: Spring Security | Dependency: jjwt, bcrypt


3.2 Лаб 1: Spring Security + JWT Authentication

Алхам 1: Dependency нэмэх (pom.xml)

<!-- Spring Security -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!-- JWT -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.3</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.3</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.3</version>
    <scope>runtime</scope>
</dependency>

<!-- Validation -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Алхам 2: User Entity

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String password;  // bcrypt hash хадгална

    @Column(nullable = false)
    private String name;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;  // ADMIN, TEACHER, STUDENT

    // Getters, Setters
}

public enum Role {
    ADMIN, TEACHER, STUDENT
}

Алхам 3: JWT Service

@Service
public class JwtService {

    @Value("${jwt.secret}")
    private String secretKey;

    @Value("${jwt.expiration}")
    private long expiration;  // 86400000 = 24 цаг

    // Token үүсгэх
    public String generateToken(User user) {
        return Jwts.builder()
            .subject(user.getEmail())
            .claim("role", user.getRole().name())
            .claim("name", user.getName())
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + expiration))
            .signWith(getSigningKey())
            .compact();
    }

    // Token-аас email авах
    public String extractEmail(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    // Token хүчинтэй эсэх
    public boolean isTokenValid(String token, UserDetails userDetails) {
        String email = extractEmail(token);
        return email.equals(userDetails.getUsername()) && !isTokenExpired(token);
    }

    private boolean isTokenExpired(String token) {
        return extractClaim(token, Claims::getExpiration).before(new Date());
    }

    private <T> T extractClaim(String token, Function<Claims, T> resolver) {
        Claims claims = Jwts.parser()
            .verifyWith(getSigningKey())
            .build()
            .parseSignedClaims(token)
            .getPayload();
        return resolver.apply(claims);
    }

    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

Алхам 4: JWT Authentication Filter

@Component
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    public JwtAuthFilter(JwtService jwtService, UserDetailsService userDetailsService) {
        this.jwtService = jwtService;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain filterChain)
            throws ServletException, IOException {

        // 1. Authorization header авах
        String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        // 2. Token задлах
        String token = authHeader.substring(7);
        String email = jwtService.extractEmail(token);

        // 3. Authentication тохируулах
        if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(email);
            if (jwtService.isTokenValid(token, userDetails)) {
                UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }

        filterChain.doFilter(request, response);
    }
}

Алхам 5: Auth Controller

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthService authService;

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping("/register")
    public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
        return ResponseEntity.ok(authService.register(request));
    }

    @PostMapping("/login")
    public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
        return ResponseEntity.ok(authService.login(request));
    }
}

// Request / Response DTO
public record RegisterRequest(
    @NotBlank String name,
    @NotBlank @Email String email,
    @NotBlank @Size(min = 8) String password
) {}

public record LoginRequest(
    @NotBlank @Email String email,
    @NotBlank String password
) {}

public record AuthResponse(String token, String email, String role) {}

Алхам 6: Auth Service

@Service
public class AuthService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtService jwtService;
    private final AuthenticationManager authenticationManager;

    // Constructor injection...

    public AuthResponse register(RegisterRequest request) {
        // Email давтагдаагүй эсэх
        if (userRepository.existsByEmail(request.email())) {
            throw new RuntimeException("Email бүртгэлтэй байна");
        }

        // User үүсгэх
        User user = new User();
        user.setName(request.name());
        user.setEmail(request.email());
        user.setPassword(passwordEncoder.encode(request.password())); // bcrypt!
        user.setRole(Role.STUDENT);
        userRepository.save(user);

        // JWT token үүсгэх
        String token = jwtService.generateToken(user);
        return new AuthResponse(token, user.getEmail(), user.getRole().name());
    }

    public AuthResponse login(LoginRequest request) {
        // Authentication шалгах
        authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(request.email(), request.password())
        );

        User user = userRepository.findByEmail(request.email())
            .orElseThrow(() -> new RuntimeException("Хэрэглэгч олдсонгүй"));

        String token = jwtService.generateToken(user);
        return new AuthResponse(token, user.getEmail(), user.getRole().name());
    }
}

3.3 Лаб 2: Role-Based Authorization

Алхам 1: Security Config

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtAuthFilter jwtAuthFilter;
    private final UserDetailsService userDetailsService;

    // Constructor injection...

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers(HttpMethod.DELETE, "/api/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(12);
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
            throws Exception {
        return config.getAuthenticationManager();
    }
}

Алхам 2: Method-Level Security

@RestController
@RequestMapping("/api/students")
public class StudentController {

    // Бүгд харах боломжтой
    @GetMapping
    @PreAuthorize("hasAnyRole('ADMIN', 'TEACHER', 'STUDENT')")
    public List<StudentResponse> getAll() { ... }

    // Зөвхөн ADMIN, TEACHER нэмж чадна
    @PostMapping
    @PreAuthorize("hasAnyRole('ADMIN', 'TEACHER')")
    public StudentResponse create(@Valid @RequestBody CreateStudentRequest request) { ... }

    // Зөвхөн ADMIN устгаж чадна
    @DeleteMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN')")
    public void delete(@PathVariable Long id) { ... }

    // Өөрийнхөө мэдээлэл эсвэл ADMIN
    @GetMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN') or @securityService.isOwner(#id, authentication)")
    public StudentResponse getById(@PathVariable Long id) { ... }
}

3.4 Лаб 3: Input Validation + Error Handling

Алхам 1: Validation DTO

public class CreateStudentRequest {

    @NotBlank(message = "Нэр заавал оруулна")
    @Size(min = 2, max = 100, message = "Нэр 2-100 тэмдэгт байна")
    private String name;

    @NotBlank(message = "Email заавал оруулна")
    @Email(message = "Email формат буруу")
    private String email;

    @NotNull(message = "GPA заавал оруулна")
    @DecimalMin(value = "0.0", message = "GPA 0-оос бага байж болохгүй")
    @DecimalMax(value = "4.0", message = "GPA 4.0-оос их байж болохгүй")
    private BigDecimal gpa;

    @NotNull(message = "Тэнхим заавал сонгоно")
    private Long departmentId;

    // Getters, Setters
}

Алхам 2: Global Exception Handler

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Validation алдаа
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
            errors.put(error.getField(), error.getDefaultMessage())
        );
        return ResponseEntity.badRequest()
            .body(new ErrorResponse(400, "Оролтын алдаа", errors));
    }

    // Authentication алдаа
    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex) {
        return ResponseEntity.status(403)
            .body(new ErrorResponse(403, "Хандах эрхгүй", null));
    }

    // Ерөнхий алдаа — Дэлгэрэнгүй мэдээлэл БУЦААХГҮЙ (аюулгүй)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
        // Production: Дотоод алдааг хэрэглэгчид ХАРУУЛАХГҮЙ
        log.error("Internal error: ", ex);
        return ResponseEntity.status(500)
            .body(new ErrorResponse(500, "Серверийн алдаа", null));
    }
}

public record ErrorResponse(int status, String message, Object errors) {}

⚠️ Чухал: Production-д stack trace, DB алдаа зэрэг дотоод мэдээллийг хэрэглэгчид ХЭЗЭЭ Ч буцаахгүй! Халдагчид ашиглана.


3.5 Лаб 4: CORS + Security Headers + application.yml

Алхам 1: application.yml тохиргоо

# application.yml
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/studentdb
    username: ${DB_USERNAME}       # ENV variable → Кодонд password байхгүй!
    password: ${DB_PASSWORD}

jwt:
  secret: ${JWT_SECRET}            # ENV variable
  expiration: 86400000             # 24 цаг (ms)

server:
  error:
    include-message: never         # Алдааны дэлгэрэнгүй НУУХ
    include-stacktrace: never      # Stack trace НУУХ

Алхам 2: CORS тохиргоо

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("https://myapp.com")    // Зөвхөн энэ domain!
            .allowedMethods("GET", "POST", "PUT", "DELETE")
            .allowedHeaders("Authorization", "Content-Type")
            .allowCredentials(true)
            .maxAge(3600);
    }
}

Алхам 3: Security Headers

// SecurityConfig дотор
http.headers(headers -> headers
    .contentSecurityPolicy(csp ->
        csp.policyDirectives("default-src 'self'; script-src 'self'"))
    .frameOptions(frame -> frame.deny())
    .httpStrictTransportSecurity(hsts ->
        hsts.maxAgeInSeconds(31536000).includeSubDomains(true))
    .contentTypeOptions(Customizer.withDefaults())
);

Тест хийх:

# 1. Бүртгүүлэх
curl -X POST http://localhost:8080/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name":"Бат","email":"bat@test.com","password":"Password123!"}'

# Хариу: {"token":"eyJ...","email":"bat@test.com","role":"STUDENT"}

# 2. Нэвтрэх
curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"bat@test.com","password":"Password123!"}'

# 3. JWT token-тэй хүсэлт
curl -X GET http://localhost:8080/api/students \
  -H "Authorization: Bearer eyJ..."

# 4. Эрхгүй хүсэлт (STUDENT → DELETE)
curl -X DELETE http://localhost:8080/api/students/1 \
  -H "Authorization: Bearer eyJ..."
# → 403 Forbidden