Skip to content

Commit 213b008

Browse files
committed
feat(refresh-token): implement refresh token with blacklist mechanism
- add endpoint logout to revoke refesh token - add logic to check blacklist refresh token
1 parent 501238f commit 213b008

File tree

8 files changed

+186
-21
lines changed

8 files changed

+186
-21
lines changed

app.http

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
@username = alice.johnson@example.com
44
@passowrd = password
55
@token = eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJhcHBzIiwic3ViIjoiYm9iLnNtaXRoQGV4YW1wbGUuY29tIiwiZXhwIjoxNzQ5MzY2MjU0LCJ1c2VyX2lkIjoyLCJpYXQiOjE3NDkzNjYyMzQsInNjb3BlIjoiIn0.FBStVWnq6h45QVXN7oJA6wxLRDW0OoRCWkxUBstP4PfzKJ81UVJzFt8s5N5dEu1dKc5vd66lOIvDw9d6QfGs2DMNf3LYH53X9r1zhNi0fIsShYSWnrCNahYi7zaRmaGys4cUMBehNj4VQGF-fbxW3UdCgbGxsDlPvJviz76Nfo7TyVZlV9ccVJd1mKJHm0E06H_FK8qab_Rq40jLMMt8B5OM3qdNLN_j6fGds-xXclIsT_W-EaNecfhCcDJ_lkqxsJ-sEIrtpiEp4MD1SqMzbXsyoXKKu6gHzwEeGpI6_giQrdCLf2wFEKBhzPeH-9hKGj8afjw8UWTVXXE4zV8HnA
6-
@refresh = eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJhcHBzIiwic3ViIjoiYW5kcmlhd2FuIiwiZXhwIjoxNzQ5MjY1MTgyLCJpYXQiOjE3NDkyNjUwNjIsInNjb3BlIjoiIn0.SVX9hYqVBs4b-7yPUZkBOXSRyGN7n7AW2A5XsMdJws40UBJvpBzztl2ir7L7C8A1FvjOQ59Mz5rAGgXH9xytPwXhRF8KHIXZF_aUO--cYhbjleFPjA1J_Y0QtI5ncEoLz6wMHDuz04_xjvbHaejSVo1dgbcZsq3iYc3UrL_Rgz-CQiwARk8CsOeevAGukxz_px-IMYPA2BIypWHcdbsqkgzcjTMAY0xBjGTEND97p1omvPPIDkT-pln8k3zyUEjY9qeEUT1AJwDB_HgoXePTSeW08oVZvrcWeOSdiEP3q9iuIVo93LjVOApcHJyGWzjEqYe_h1mrm2ciQ541BbzsTQ
6+
@refresh = eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJhcHBzIiwic3ViIjoiYWxpY2Uuam9obnNvbkBleGFtcGxlLmNvbSIsImV4cCI6MTc0OTM4NzY5OCwidXNlcl9pZCI6MSwiaWF0IjoxNzQ5Mzg3NTc4LCJzY29wZSI6IiJ9.iqcexuIGb8m-Z6mUKzRHsksl3GTQoc6wamQGlJhDB-hAiU7YrgJmKdLA4aD3XNbRTi6aiaGS0R5N5GCBh-_O81sFywa_stNgJUEA-o-SnTeBdaJsD1ITPPzu9xivyDR6vhz6LrW1mukwm4mCcR15R-5w0gIU1nl5XhBX3Rggfjr-lnrqEItkWglKtY1haJ_Aemc9EmoCxwSsLZ_OEbKdkzyCxcYcJ_5IvHeU98Z_DzhmnxyFBQXUFYTbENA162P3YL99dGLWSNBQ30akUYacjYT3aF4zif1qAH8M42tgb-OKt9DXlKWsyojyAvH2qQWT8eu5o4uxs1L8Gw5PuQk-JA
77
GET {{api}}/users
88
Accept: application/json
99
Authorization: Bearer {{token}}
@@ -23,5 +23,9 @@ Authorization: Basic {{username}}:{{passowrd}}
2323

2424
POST {{api}}/auth/token/refresh
2525
Accept: application/json
26-
Authorization: Bearer {{refresh}}
26+
Content-Type: application/json
27+
28+
{
29+
"token": "{{refresh}}"
30+
}
2731

src/main/java/com/andriawan/andresource/config/CustomAuthenticationEntryPoint.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public void commence(
3939
responseForJson.status(),
4040
"message",
4141
responseForJson.message(),
42-
"exception",
42+
"exception",
4343
responseForJson.exceptionClassName(),
4444
"path",
4545
request.getRequestURI());

src/main/java/com/andriawan/andresource/config/Security.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ public class Security {
4242
@Value("${auth.sample.password}")
4343
private String samplePassword;
4444

45-
private String[] publicRoute = {"/v3/api-docs/*", "/v3/api-docs", "/swagger-ui/*"};
45+
private String[] publicRoute = {
46+
"/v3/api-docs/*", "/v3/api-docs", "/swagger-ui/*", "/api/v1/auth/token/refresh"
47+
};
4648

4749
private String loginRoute = "/api/v1/auth/login";
4850

src/main/java/com/andriawan/andresource/controller/AuthController.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
import java.util.Map;
66
import java.util.Optional;
77
import lombok.extern.slf4j.Slf4j;
8+
import org.apache.tomcat.websocket.AuthenticationException;
89
import org.springframework.beans.factory.annotation.Autowired;
910
import org.springframework.http.ResponseEntity;
1011
import org.springframework.security.authentication.BadCredentialsException;
1112
import org.springframework.security.core.Authentication;
1213
import org.springframework.web.bind.annotation.PostMapping;
14+
import org.springframework.web.bind.annotation.RequestBody;
1315
import org.springframework.web.bind.annotation.RequestMapping;
1416
import org.springframework.web.bind.annotation.RestController;
1517

@@ -19,6 +21,8 @@
1921
public class AuthController {
2022
@Autowired private TokenService tokenService;
2123

24+
public record RefreshTokenRequest(String token) {}
25+
2226
@SecurityRequirement(name = "basicAuth")
2327
@PostMapping("/login")
2428
public ResponseEntity<Map<String, String>> authenticate(Authentication authentication) {
@@ -31,8 +35,16 @@ public ResponseEntity<Map<String, String>> authenticate(Authentication authentic
3135
}
3236

3337
@SecurityRequirement(name = "jwt")
38+
@PostMapping("/logout")
39+
public ResponseEntity<Map<String, String>> logout(
40+
Authentication authentication, @RequestBody RefreshTokenRequest request) {
41+
tokenService.revokeToken(request);
42+
return ResponseEntity.ok(Map.of("message", "token deleted"));
43+
}
44+
3445
@PostMapping("/token/refresh")
35-
public ResponseEntity<Map<String, String>> refreshToken(Authentication authentication) {
36-
return ResponseEntity.ok(tokenService.generateToken(authentication));
46+
public ResponseEntity<Map<String, String>> refreshToken(@RequestBody RefreshTokenRequest request)
47+
throws AuthenticationException {
48+
return ResponseEntity.ok(tokenService.doRefreshToken(request));
3749
}
3850
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.andriawan.andresource.entity;
2+
3+
import jakarta.persistence.*;
4+
import java.time.Instant;
5+
import java.util.UUID;
6+
import lombok.*;
7+
8+
@Entity
9+
@Table(name = "refresh_token", schema = "auth")
10+
@Data
11+
@NoArgsConstructor
12+
@AllArgsConstructor
13+
@Builder
14+
public class RefreshToken {
15+
16+
@Id
17+
@GeneratedValue(strategy = GenerationType.IDENTITY)
18+
private Integer id;
19+
20+
@Column(nullable = false, unique = true, columnDefinition = "TEXT")
21+
private String token;
22+
23+
@Column(name = "user_id", insertable = false, updatable = false)
24+
private Integer userId;
25+
26+
@ManyToOne(fetch = FetchType.LAZY)
27+
@JoinColumn(name = "user_id", foreignKey = @ForeignKey(name = "fk_user"))
28+
private User user;
29+
30+
@Column(name = "expires_at", nullable = false)
31+
private Instant expiresAt;
32+
33+
@Column(name = "blacklisted_at")
34+
private Instant blacklistedAt;
35+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.andriawan.andresource.repository;
2+
3+
import com.andriawan.andresource.entity.RefreshToken;
4+
import java.util.Optional;
5+
import org.springframework.data.jpa.repository.JpaRepository;
6+
7+
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
8+
Optional<RefreshToken> findByToken(String token);
9+
10+
void deleteByToken(String token);
11+
}
Lines changed: 90 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
package com.andriawan.andresource.service;
22

3+
import com.andriawan.andresource.controller.AuthController.RefreshTokenRequest;
4+
import com.andriawan.andresource.entity.RefreshToken;
5+
import com.andriawan.andresource.entity.User;
6+
import com.andriawan.andresource.repository.RefreshTokenRepository;
7+
import jakarta.persistence.EntityNotFoundException;
8+
import jakarta.transaction.Transactional;
39
import java.time.Instant;
410
import java.time.temporal.ChronoUnit;
511
import java.util.Map;
6-
import java.util.stream.Collectors;
12+
import org.springframework.beans.factory.annotation.Autowired;
713
import org.springframework.beans.factory.annotation.Value;
14+
import org.springframework.security.authentication.BadCredentialsException;
15+
import org.springframework.security.authentication.InsufficientAuthenticationException;
816
import org.springframework.security.core.Authentication;
9-
import org.springframework.security.core.GrantedAuthority;
17+
import org.springframework.security.core.AuthenticationException;
18+
import org.springframework.security.oauth2.jwt.BadJwtException;
19+
import org.springframework.security.oauth2.jwt.Jwt;
1020
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
21+
import org.springframework.security.oauth2.jwt.JwtDecoder;
1122
import org.springframework.security.oauth2.jwt.JwtEncoder;
1223
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
1324
import org.springframework.stereotype.Service;
@@ -23,43 +34,107 @@ public class TokenService {
2334

2435
private final JwtEncoder jwtEncoder;
2536

26-
public record JwtClaimsSetParams(
27-
long time, Instant now, String scope, Authentication authentication) {}
37+
private final JwtDecoder jwtDecoder;
2838

29-
public TokenService(JwtEncoder jwtEncoder) {
39+
@Autowired JpaUserDetailsService jpaUserDetailsService;
40+
@Autowired RefreshTokenRepository refreshTokenRepository;
41+
42+
public record JwtClaimsSetParams(long time, Instant now, String scope, String username) {}
43+
44+
public TokenService(JwtEncoder jwtEncoder, JwtDecoder jwtDecoder) {
3045
this.jwtEncoder = jwtEncoder;
46+
this.jwtDecoder = jwtDecoder;
3147
}
3248

3349
public Map<String, String> generateToken(Authentication authentication) {
50+
return generateToken(authentication.getName());
51+
}
52+
53+
public Map<String, String> generateToken(User user) {
54+
return generateToken(user.getEmail());
55+
}
56+
57+
public Map<String, String> generateToken(String username) {
3458
Instant now = Instant.now();
35-
String scope =
36-
authentication.getAuthorities().stream()
37-
.map(GrantedAuthority::getAuthority)
38-
.collect(Collectors.joining(" "));
59+
String scope = "";
3960
JwtClaimsSet claimsSetAccessToken =
40-
buildJwtClaimsSet(new JwtClaimsSetParams(expiredTokenSeconds, now, scope, authentication));
61+
buildJwtClaimsSet(new JwtClaimsSetParams(expiredTokenSeconds, now, scope, username));
4162
JwtClaimsSet claimsSetRefreshToken =
42-
buildJwtClaimsSet(
43-
new JwtClaimsSetParams(expiredRefreshSeconds, now, scope, authentication));
63+
buildJwtClaimsSet(new JwtClaimsSetParams(expiredRefreshSeconds, now, scope, username));
4464
String accessToken = encodeToken(claimsSetAccessToken);
45-
String refreshToken = encodeToken(claimsSetRefreshToken);
65+
String refreshToken = setupRefreshToken(claimsSetRefreshToken);
4666
return Map.of("access_token", accessToken, "refresh_token", refreshToken);
4767
}
4868

69+
public String setupRefreshToken(JwtClaimsSet claimsSetRefreshToken) {
70+
String token = encodeToken(claimsSetRefreshToken);
71+
refreshTokenRepository.save(
72+
RefreshToken.builder()
73+
.token(token)
74+
.user(jpaUserDetailsService.getAuthenticatedUser().getUser())
75+
.expiresAt(claimsSetRefreshToken.getExpiresAt())
76+
.build());
77+
return token;
78+
}
79+
80+
public void backlistToken(String token) {
81+
RefreshToken refreshToken =
82+
refreshTokenRepository
83+
.findByToken(token)
84+
.orElseThrow(() -> new EntityNotFoundException("token not found"));
85+
refreshToken.setBlacklistedAt(Instant.now());
86+
refreshTokenRepository.save(refreshToken);
87+
}
88+
89+
public boolean isBlacklistedRefreshToken(String token) {
90+
boolean state =
91+
refreshTokenRepository
92+
.findByToken(token)
93+
.map(mapToken -> mapToken.getBlacklistedAt() != null)
94+
.orElse(true);
95+
return state;
96+
}
97+
4998
private JwtClaimsSet buildJwtClaimsSet(JwtClaimsSetParams params) {
99+
100+
Long userId = jpaUserDetailsService.getAuthenticatedUser().getUser().getId();
101+
50102
JwtClaimsSet claimsSet =
51103
JwtClaimsSet.builder()
52104
.issuer("apps")
53105
.issuedAt(params.now())
54106
.expiresAt(params.now().plus(params.time(), ChronoUnit.SECONDS))
55-
.subject(params.authentication().getName())
107+
.subject(params.username())
56108
.claim("scope", params.scope())
57-
.claim("user_id", "1")
109+
.claim("user_id", userId)
58110
.build();
59111
return claimsSet;
60112
}
61113

62114
public String encodeToken(JwtClaimsSet jwtClaimsSet) {
63115
return this.jwtEncoder.encode(JwtEncoderParameters.from(jwtClaimsSet)).getTokenValue();
64116
}
117+
118+
public void revokeToken(RefreshTokenRequest request) {
119+
refreshTokenRepository.deleteByToken(request.token());
120+
}
121+
122+
@Transactional(dontRollbackOn = BadCredentialsException.class)
123+
public Map<String, String> doRefreshToken(RefreshTokenRequest request)
124+
throws AuthenticationException {
125+
try {
126+
Jwt jwt = jwtDecoder.decode(request.token());
127+
if (isBlacklistedRefreshToken(request.token())) {
128+
refreshTokenRepository.deleteByToken(request.token());
129+
throw new BadCredentialsException("refresh token is blacklisted");
130+
}
131+
Map<String, String> response = generateToken(jwt.getSubject());
132+
backlistToken(request.token());
133+
return response;
134+
} catch (InsufficientAuthenticationException | BadJwtException e) {
135+
throw new BadCredentialsException(e.getMessage());
136+
} catch (Exception e) {
137+
throw e;
138+
}
139+
}
65140
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
-- V3__create_refresh_token.sql
2+
3+
-- Create the auth schema if it doesn't exist
4+
CREATE SCHEMA IF NOT EXISTS auth;
5+
6+
-- Create the refresh_token table
7+
CREATE TABLE IF NOT EXISTS auth.refresh_token (
8+
id SERIAL PRIMARY KEY,
9+
token TEXT NOT NULL UNIQUE,
10+
user_id INTEGER,
11+
expires_at TIMESTAMPTZ NOT NULL,
12+
blacklisted_at TIMESTAMPTZ,
13+
14+
CONSTRAINT fk_user
15+
FOREIGN KEY (user_id)
16+
REFERENCES public.users(id)
17+
ON DELETE SET NULL
18+
);
19+
20+
-- Index for quick lookup by token
21+
CREATE INDEX IF NOT EXISTS idx_refresh_token_token
22+
ON auth.refresh_token (token);
23+
24+
-- Index to help with cleanup of expired tokens
25+
CREATE INDEX IF NOT EXISTS idx_refresh_token_blacklisted_at
26+
ON auth.refresh_token (blacklisted_at);

0 commit comments

Comments
 (0)