Skip to content

Commit c68a104

Browse files
feat: add role-based authorization with JWT role claims
Agent-Logs-Url: https://github.com/vitorhugo-java/SpringBoot-JobApplyTracker/sessions/26afd256-5bd0-476c-910c-64e37aedb6aa Co-authored-by: vitorhugo-java <65777252+vitorhugo-java@users.noreply.github.com>
1 parent 248e3bf commit c68a104

17 files changed

Lines changed: 359 additions & 34 deletions

README.md

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ A production-ready Spring Boot REST API for tracking job applications, built wit
88

99
- **Java 21**
1010
- **Spring Boot 3.2** (Web, Data JPA, Security, Validation)
11-
- **Spring Security** with stateless JWT authentication
11+
- **Spring Security** with stateless JWT authentication + role-based authorization (`USER`, `BETA`, `ADMIN`)
1212
- **JWT + Refresh Tokens** (access: 15 min, refresh: 7 days with rotation)
1313
- **Resilience4j Rate Limiting** on auth endpoints
1414
- **MariaDB** (production) / **Testcontainers** (tests)
@@ -52,27 +52,38 @@ A production-ready Spring Boot REST API for tracking job applications, built wit
5252

5353
| Method | Endpoint | Description |
5454
|--------|----------|-------------|
55-
| POST | `/api/auth/register` | Register a new user |
56-
| POST | `/api/auth/login` | Login and receive tokens |
57-
| POST | `/api/auth/refresh` | Refresh access token |
58-
| POST | `/api/auth/logout` | Logout and revoke refresh token |
59-
| POST | `/api/auth/forgot-password` | Request password reset |
60-
| POST | `/api/auth/reset-password` | Reset password with token |
61-
| GET | `/api/auth/me` | Get current user info |
55+
| POST | `/api/v1/auth/register` | Register a new user |
56+
| POST | `/api/v1/auth/login` | Login and receive tokens |
57+
| POST | `/api/v1/auth/refresh` | Refresh access token |
58+
| POST | `/api/v1/auth/logout` | Logout and revoke refresh token |
59+
| POST | `/api/v1/auth/forgot-password` | Request password reset |
60+
| POST | `/api/v1/auth/reset-password` | Reset password with token |
61+
| GET | `/api/v1/auth/me` | Get current user info |
6262

6363
### Applications
6464

6565
| Method | Endpoint | Description |
6666
|--------|----------|-------------|
67-
| POST | `/api/applications` | Create application |
68-
| GET | `/api/applications` | List all (paginated + filterable) |
69-
| GET | `/api/applications/{id}` | Get by ID |
70-
| PUT | `/api/applications/{id}` | Full update |
71-
| PATCH | `/api/applications/{id}/status` | Update status |
72-
| PATCH | `/api/applications/{id}/reminder` | Toggle reminder |
73-
| DELETE | `/api/applications/{id}` | Delete |
74-
| GET | `/api/applications/upcoming` | Upcoming next steps |
75-
| GET | `/api/applications/overdue` | Overdue next steps |
67+
| POST | `/api/v1/applications` | Create application |
68+
| GET | `/api/v1/applications` | List all (paginated + filterable) |
69+
| GET | `/api/v1/applications/{id}` | Get by ID |
70+
| PUT | `/api/v1/applications/{id}` | Full update |
71+
| PATCH | `/api/v1/applications/{id}/status` | Update status |
72+
| PATCH | `/api/v1/applications/{id}/reminder` | Toggle reminder |
73+
| DELETE | `/api/v1/applications/{id}` | Delete |
74+
| GET | `/api/v1/applications/upcoming` | Upcoming next steps |
75+
| GET | `/api/v1/applications/overdue` | Overdue next steps |
76+
77+
## Authorization Model
78+
79+
- JWT access tokens now include a `roles` claim (e.g., `ROLE_USER`, `ROLE_ADMIN`).
80+
- Protected API routes require `ROLE_USER` (auth routes remain public).
81+
- A default `ROLE_USER` is assigned on registration.
82+
83+
Flyway seeds the roles catalog (`USER`, `BETA`, `ADMIN`) and sample accounts:
84+
85+
- `admin@jobtracker.local` / `Admin@1234` (`ROLE_ADMIN` + `ROLE_USER`)
86+
- `user@jobtracker.local` / `User@1234` (`ROLE_USER`)
7687

7788
### Gamification
7889

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Story: Role-based Authorization (USER, BETA, ADMIN)
2+
3+
## Checklist
4+
5+
- [x] Add Role entity/model and `User``Role` many-to-many mapping
6+
- [x] Add `RoleRepository`
7+
- [x] Add Flyway migration for `roles`, `user_roles`, role seed, and sample admin/user assignments
8+
- [x] Include roles in JWT generation/parsing and authentication authorities
9+
- [x] Enforce baseline authorization (`ROLE_USER`) for protected API endpoints
10+
- [x] Add unit tests for JWT role claims and `UserDetailsService` role mapping
11+
- [x] Add integration test proving authenticated token without `ROLE_USER` is forbidden
12+
- [x] Update README with role-based auth and seeded account details
13+
14+
## File List
15+
16+
- `src/main/java/com/jobtracker/entity/enums/RoleName.java`
17+
- `src/main/java/com/jobtracker/entity/Role.java`
18+
- `src/main/java/com/jobtracker/entity/User.java`
19+
- `src/main/java/com/jobtracker/repository/RoleRepository.java`
20+
- `src/main/java/com/jobtracker/config/ApplicationConfig.java`
21+
- `src/main/java/com/jobtracker/config/JwtService.java`
22+
- `src/main/java/com/jobtracker/config/JwtAuthenticationFilter.java`
23+
- `src/main/java/com/jobtracker/config/SecurityConfig.java`
24+
- `src/main/java/com/jobtracker/service/AuthService.java`
25+
- `src/main/resources/db/migration/V11__add_roles_and_user_authorization.sql`
26+
- `src/test/java/com/jobtracker/unit/ApplicationConfigTest.java`
27+
- `src/test/java/com/jobtracker/unit/JwtServiceTest.java`
28+
- `src/test/java/com/jobtracker/unit/AuthServiceTest.java`
29+
- `src/test/java/com/jobtracker/integration/AuthControllerIT.java`
30+
- `README.md`
31+
- `docs/stories/role-based-authorization.md`

src/main/java/com/jobtracker/config/ApplicationConfig.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66
import org.springframework.security.authentication.AuthenticationManager;
77
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
88
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
9+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
910
import org.springframework.security.core.userdetails.UserDetailsService;
1011
import org.springframework.security.core.userdetails.UsernameNotFoundException;
1112
import org.springframework.security.crypto.password.PasswordEncoder;
12-
13-
import java.util.Collections;
13+
import java.util.stream.Collectors;
1414

1515
@Configuration
1616
public class ApplicationConfig {
@@ -27,7 +27,9 @@ public UserDetailsService userDetailsService() {
2727
.map(user -> new org.springframework.security.core.userdetails.User(
2828
user.getEmail(),
2929
user.getPasswordHash(),
30-
Collections.emptyList()))
30+
user.getRoles().stream()
31+
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName().name()))
32+
.collect(Collectors.toSet())))
3133
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
3234
}
3335

src/main/java/com/jobtracker/config/JwtAuthenticationFilter.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ protected void doFilterInternal(HttpServletRequest request,
5353

5454
if (userDetails != null && jwtService.isTokenValid(jwt, userDetails)) {
5555
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
56-
userDetails, null, userDetails.getAuthorities());
56+
userDetails, null, jwtService.extractAuthorities(jwt));
5757
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
5858
SecurityContextHolder.getContext().setAuthentication(authToken);
5959
}

src/main/java/com/jobtracker/config/JwtService.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,24 @@
44
import io.jsonwebtoken.Jwts;
55
import io.jsonwebtoken.security.Keys;
66
import org.springframework.beans.factory.annotation.Value;
7+
import org.springframework.security.core.GrantedAuthority;
8+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
79
import org.springframework.security.core.userdetails.UserDetails;
810
import org.springframework.stereotype.Service;
911

1012
import javax.crypto.SecretKey;
1113
import java.nio.charset.StandardCharsets;
14+
import java.util.Collection;
1215
import java.util.Date;
1316
import java.util.HashMap;
17+
import java.util.List;
1418
import java.util.Map;
1519
import java.util.function.Function;
20+
import java.util.stream.Collectors;
1621

1722
@Service
1823
public class JwtService {
24+
private static final String ROLES_CLAIM = "roles";
1925

2026
@Value("${jwt.secret}")
2127
private String secretKey;
@@ -33,7 +39,11 @@ public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
3339
}
3440

3541
public String generateToken(UserDetails userDetails) {
36-
return generateToken(new HashMap<>(), userDetails);
42+
Map<String, Object> claims = new HashMap<>();
43+
claims.put(ROLES_CLAIM, userDetails.getAuthorities().stream()
44+
.map(GrantedAuthority::getAuthority)
45+
.toList());
46+
return generateToken(claims, userDetails);
3747
}
3848

3949
public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
@@ -51,6 +61,18 @@ public boolean isTokenValid(String token, UserDetails userDetails) {
5161
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
5262
}
5363

64+
public Collection<SimpleGrantedAuthority> extractAuthorities(String token) {
65+
Object rolesClaim = extractAllClaims(token).get(ROLES_CLAIM);
66+
if (!(rolesClaim instanceof List<?> roleValues)) {
67+
return List.of();
68+
}
69+
return roleValues.stream()
70+
.filter(String.class::isInstance)
71+
.map(String.class::cast)
72+
.map(SimpleGrantedAuthority::new)
73+
.collect(Collectors.toSet());
74+
}
75+
5476
private boolean isTokenExpired(String token) {
5577
return extractExpiration(token).before(new Date());
5678
}

src/main/java/com/jobtracker/config/SecurityConfig.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,18 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
3838
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
3939
.authorizeHttpRequests(auth -> auth
4040
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
41-
.requestMatchers("/api/v1/auth/**").permitAll()
41+
.requestMatchers(HttpMethod.POST,
42+
"/api/v1/auth/register",
43+
"/api/v1/auth/login",
44+
"/api/v1/auth/refresh",
45+
"/api/v1/auth/forgot-password",
46+
"/api/v1/auth/reset-password",
47+
"/api/v1/auth/logout").permitAll()
4248
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
4349
// Actuator is served on a dedicated management port (8081) that is never
4450
// exposed to the host; security is enforced via Docker network isolation.
4551
.requestMatchers("/actuator/**").permitAll()
46-
.anyRequest().authenticated())
52+
.anyRequest().hasRole("USER"))
4753
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
4854
.addFilterBefore(requestLoggingFilter, JwtAuthenticationFilter.class);
4955

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.jobtracker.entity;
2+
3+
import com.jobtracker.entity.enums.RoleName;
4+
import jakarta.persistence.Column;
5+
import jakarta.persistence.Entity;
6+
import jakarta.persistence.EnumType;
7+
import jakarta.persistence.Enumerated;
8+
import jakarta.persistence.GeneratedValue;
9+
import jakarta.persistence.GenerationType;
10+
import jakarta.persistence.Id;
11+
import jakarta.persistence.Table;
12+
13+
@Entity
14+
@Table(name = "roles")
15+
public class Role {
16+
17+
@Id
18+
@GeneratedValue(strategy = GenerationType.IDENTITY)
19+
private Long id;
20+
21+
@Enumerated(EnumType.STRING)
22+
@Column(nullable = false, unique = true, length = 20)
23+
private RoleName name;
24+
25+
public Long getId() {
26+
return id;
27+
}
28+
29+
public RoleName getName() {
30+
return name;
31+
}
32+
33+
public void setName(RoleName name) {
34+
this.name = name;
35+
}
36+
}

src/main/java/com/jobtracker/entity/User.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import jakarta.persistence.*;
44
import java.time.LocalTime;
55
import java.time.LocalDateTime;
6+
import java.util.HashSet;
7+
import java.util.Set;
68
import java.util.UUID;
79

810
import org.hibernate.annotations.UuidGenerator;
@@ -36,6 +38,10 @@ public class User {
3638
@Column(name = "updated_at", nullable = false)
3739
private LocalDateTime updatedAt;
3840

41+
@ManyToMany(fetch = FetchType.EAGER)
42+
@JoinTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "role_id"))
43+
private Set<Role> roles = new HashSet<>();
44+
3945
@PrePersist
4046
protected void onCreate() {
4147
if (reminderTime == null) {
@@ -70,4 +76,7 @@ protected void onUpdate() {
7076

7177
public LocalDateTime getUpdatedAt() { return updatedAt; }
7278
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
79+
80+
public Set<Role> getRoles() { return roles; }
81+
public void setRoles(Set<Role> roles) { this.roles = roles; }
7382
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.jobtracker.entity.enums;
2+
3+
public enum RoleName {
4+
USER,
5+
BETA,
6+
ADMIN
7+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.jobtracker.repository;
2+
3+
import com.jobtracker.entity.Role;
4+
import com.jobtracker.entity.enums.RoleName;
5+
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.stereotype.Repository;
7+
8+
import java.util.Optional;
9+
10+
@Repository
11+
public interface RoleRepository extends JpaRepository<Role, Long> {
12+
Optional<Role> findByName(RoleName name);
13+
}

0 commit comments

Comments
 (0)