Rešiti se Session-a? Ovo rešenje za cross-domain autentifikaciju je zaista elegantno!
Rešiti se Session-a? Ovo rešenje za cross-domain autentifikaciju je zaista elegantno!
Autentifikacija prijavljivanja korisnika je veoma čest poslovni proces u Web aplikacijama, a uobičajeni tok je sledeći:
- Klijent šalje korisničko ime i lozinku serveru.
- Nakon uspešne provere, server čuva relevantne podatke u trenutnoj sesiji (session), na primer vreme prijavljivanja, IP adresu prijavljivanja i sl.
- Server vraća session_id klijentu, koji ga čuva u Cookie-ju.
- Kada klijent ponovo šalje zahtev serveru, prosleđuje session_id nazad serveru.
- Nakon što server dobije session_id, vrši identifikaciju korisnika.
U slučaju jednog servera (standalone), ovaj model nema nikakvih problema, ali za Web aplikacije sa razdvojenim frontendom i backendom postaje veoma bolan. Zato se pojavilo drugo rešenje: server više ne čuva podatke sesije, već ih čuva na klijentu, a klijent pri svakom zahtevu šalje te podatke serveru na proveru. JWT (JSON Web Token) je tipičan predstavnik ovakvog rešenja.

1. O JWT-u
JWT je trenutno najpopularnije cross-domain rešenje za autentifikaciju: klijent pošalje zahtev za prijavljivanje, a server, nakon što ga primi i uspešno autentifikuje, generiše JSON objekat (prikazan ispod) i zatim ga vraća klijentu.
{
"sub": "wanger",
"created": 1645700436900,
"exp": 1646305236
}Kada klijent ponovo komunicira sa serverom, ponese ovaj JSON objekat sa sobom, kao dokaz obostranog poverenja između frontenda i backenda. Nakon što primi zahtev, server identifikuje korisnika putem JSON objekta, pa više ne mora da čuva nikakve podatke sesije.
Ako sada koristim korisničko ime wanger i lozinku 123456 da pristupim login interfejsu projekta Codingmore, stvarni JWT je niz znakova koji izgleda kao da je šifrovan.

Da bi svima bilo jasnije, kopirao sam ga na zvanični sajt JWT-a.

Leva strana, Encoded, je JWT šifrat, koji je u sredini podeljen znakom «.» na tri dela (desna strana je Decoded):
- Header (zaglavlje), opisuje metapodatke JWT-a, gde svojstvo
algoznačava algoritam potpisa (trenutno HS512); - Payload (teret), služi za čuvanje podataka koji se stvarno prosleđuju, gde svojstvo
suboznačava subjekat (stvarna vrednost je korisničko ime),createdoznačava vreme kreiranja JWT-a, aexpoznačava vreme isteka. - Signature (potpis), potpis prva dva dela, sprečava neovlašćenu izmenu podataka; ovde server mora da odredi tajni ključ (koji zna samo server), koji se ne sme otkriti klijentu, a zatim koristi algoritam potpisa naveden u Header-u da generiše potpis prema sledećoj formuli:
HMACSHA512(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
your-256-bit-secret
)Nakon izračunavanja potpisa, spojite Header, Payload i Signature u jedan niz znakova, razdvojenih znakom «.», i možete ih vratiti klijentu.
Kada klijent dobije JWT, može ga staviti u localStorage ili u Cookie.
const TokenKey = '1D596CD8-8A20-4CEC-98DD-CDC12282D65C' // createUuid()
export function getToken () {
return Cookies.get(TokenKey)
}
export function setToken (token) {
return Cookies.set(TokenKey, token)
}Kasnije, kada klijent komunicira sa serverom, nosi ovaj JWT sa sobom, obično u polju Authorization zaglavlja HTTP zahteva.
Authorization: Bearer <token>
Nakon što primi zahtev, server proverava JWT, i ako provera prođe, vraća odgovarajuće resurse.
2. JWT u praksi
Prvi korak, dodajte zavisnost JWT u fajl pom.xml.
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>Drugi korak, dodajte konfiguracione stavke JWT u application.yml.
jwt:
tokenHeader: Authorization # Zaglavlje zahteva u kojem se čuva JWT
secret: codingmore-admin-secret # Tajni ključ za šifrovanje/dešifrovanje JWT-a
expiration: 604800 # Vreme isteka JWT-a (60*60*24*7)
tokenHead: 'Bearer ' # Prefiks uzet iz tereta JWT-aTreći korak, napravite novu pomoćnu klasu JwtTokenUtil.java koja uglavnom sadrži tri metode:
generateToken(UserDetails userDetails): generiše token na osnovu prijavljenog korisnikagetUserNameFromToken(String token): dobavlja prijavljenog korisnika iz tokenavalidateToken(String token, UserDetails userDetails): proverava da li je token i dalje važeći
public class JwtTokenUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
@Value("${jwt.tokenHead}")
private String tokenHead;
/**
* Generiše token na osnovu informacija o korisniku
*/
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername());
claims.put(CLAIM_KEY_CREATED, new Date());
return generateToken(claims);
}
/**
* Generiše JWT token na osnovu korisničkog imena i vremena kreiranja
*/
private String generateToken(Map<String, Object> claims) {
return Jwts.builder()
.setClaims(claims)
.setExpiration(generateExpirationDate())
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
/**
* Dobavlja korisničko ime prijavljenog korisnika iz tokena
*/
public String getUserNameFromToken(String token) {
String username = null;
Claims claims = getClaimsFromToken(token);
if (claims != null) {
username = claims.getSubject();
}
return username;
}
/**
* Dobavlja teret (Payload) iz JWT tokena
*/
private Claims getClaimsFromToken(String token) {
Claims claims = null;
try {
claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
LOGGER.info("Provera formata JWT-a nije uspela:{}", token);
}
return claims;
}
/**
* Proverava da li je token i dalje važeći
*
* @param token token koji prosleđuje klijent
* @param userDetails informacije o korisniku dobijene iz baze podataka
*/
public boolean validateToken(String token, UserDetails userDetails) {
String username = getUserNameFromToken(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
/**
* Proverava da li je token već istekao
*/
private boolean isTokenExpired(String token) {
Date expiredDate = getExpiredDateFromToken(token);
return expiredDate.before(new Date());
}
/**
* Dobavlja vreme isteka iz tokena
*/
private Date getExpiredDateFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims.getExpiration();
}
}Četvrti korak, dodajte novi login interfejs za prijavljivanje u UsersController.java, koji prima korisničko ime i lozinku, i vraća JWT klijentu.
@Controller
@Api(tags="Korisnik")
@RequestMapping("/users")
public class UsersController {
@Autowired
private IUsersService usersService;
@Value("${jwt.tokenHeader}")
private String tokenHeader;
@Value("${jwt.tokenHead}")
private String tokenHead;
@ApiOperation(value = "Vraća token nakon prijavljivanja")
@RequestMapping(value = "/login", method = RequestMethod.POST)
@ResponseBody
public ResultObject login(@Validated UsersLoginParam users, BindingResult result) {
String token = usersService.login(users.getUserLogin(), users.getUserPass());
if (token == null) {
return ResultObject.validateFailed("Pogrešno korisničko ime ili lozinka");
}
// Prosledi JWT nazad klijentu
Map<String, String> tokenMap = new HashMap<>();
tokenMap.put("token", token);
tokenMap.put("tokenHead", tokenHead);
return ResultObject.success(tokenMap);
}
}Peti korak, dodajte novu metodu login u UsersServiceImpl.java koja na osnovu korisničkog imena pronalazi korisnika u bazi, i nakon uspešne provere lozinke generiše JWT.
@Service
public class UsersServiceImpl extends ServiceImpl<UsersMapper, Users> implements IUsersService {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private JwtTokenUtil jwtTokenUtil;
public String login(String username, String password) {
String token = null;
// Lozinku treba da šifruje klijent pre slanja
try {
// Pronađi korisnika + korisničke resurse
UserDetails userDetails = loadUserByUsername(username);
// Proveri lozinku
if (!passwordEncoder.matches(password, userDetails.getPassword())) {
Asserts.fail("Lozinka nije ispravna");
}
// Vrati JWT
token = jwtTokenUtil.generateToken(userDetails);
} catch (AuthenticationException e) {
LOGGER.warn("Izuzetak pri prijavljivanju:{}", e.getMessage());
}
return token;
}
}Šesti korak, dodajte novi JwtAuthenticationTokenFilter.java koji pri svakom zahtevu klijenta proverava JWT.
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
private static final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationTokenFilter.class);
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Value("${jwt.tokenHeader}")
private String tokenHeader;
@Value("${jwt.tokenHead}")
private String tokenHead;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
// Dobavi JWT iz zahteva klijenta
String authHeader = request.getHeader(this.tokenHeader);
// Ovaj JWT je u formatu koji smo definisali, počinje sa tokenHead
if (authHeader != null && authHeader.startsWith(this.tokenHead)) {
// The part after "Bearer "
String authToken = authHeader.substring(this.tokenHead.length());
// Dobavi korisničko ime iz JWT-a
String username = jwtTokenUtil.getUserNameFromToken(authToken);
LOGGER.info("checking username:{}", username);
// SecurityContextHolder je pomoćna klasa SpringSecurity-ja
// čuva bezbednosni kontekst trenutnog korisnika aplikacije
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// Dobavi informacije o prijavljenom korisniku na osnovu korisničkog imena
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
// Proveri da li je token istekao
if (jwtTokenUtil.validateToken(authToken, userDetails)) {
// Sačuvaj prijavljenog korisnika u bezbednosni kontekst
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails,
null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
LOGGER.info("authenticated user:{}", username);
}
}
}
chain.doFilter(request, response);
}
}JwtAuthenticationTokenFilter nasleđuje OncePerRequestFilter, filter koji osigurava da jedan zahtev prođe kroz filter samo jednom, bez ponovljenog izvršavanja. Drugim rečima, svaki put kada klijent pošalje zahtev, ovaj filter se izvrši jednom.
Ovaj filter je veoma važan, skoro svakoj liniji koda sam dodao komentar. Naravno, da bih bio siguran da svi mogu da razumeju šta tačno radi ova klasa, nacrtaću još jedan dijagram toka, pa će sve biti potpuno jasno.

SpringSecurity je okvir za upravljanje bezbednošću koji se može povezati sa Spring Boot aplikacijom bez šavova. SecurityContextHolder je jedna od njegovih ključnih pomoćnih klasa koja drži informacije bezbednosnog konteksta, uključujući ko je trenutni korisnik, da li je korisnik već autentifikovan, koje dozvole korisnik ima i druge ključne podatke.
SecurityContextHolder podrazumevano koristi ThreadLocal strategiju za čuvanje informacija o autentifikaciji. ThreadLocal ima osobinu da podacima u njemu može da pristupa samo onaj thread koji ih je sačuvao. To znači da kada različiti zahtevi uđu u server, obrađuju ih različiti thread-ovi. Na primer, ako thread A sačuva informacije o korisniku iz zahteva 1 u ThreadLocal, thread B ne može da dobije te informacije o korisniku kada obrađuje zahtev 2.
Prema tome, filter JwtAuthenticationTokenFilter pri svakom dolazećem zahtevu izvršava proveru JWT-a, osiguravajući da je zahtev koji dolazi od klijenta bezbedan. Tek tada SpringSecurity propušta naredne interfejse zahteva. Ovo je i suštinska razlika između JWT-a i Session-a:
- JWT zahteva proveru pri svakom zahtevu, i sve dok JWT nije istekao, čak i ako se server restartuje, autentifikacija ostaje važeća.
- Session ne zahteva ponovnu proveru informacija o korisniku dok ne istekne, ali kada se server restartuje, korisnik mora ponovo da se prijavi kako bi dobio novi Session.
Drugim rečima, u rešenju sa JWT-om, tajni ključ (secret) koji čuva server se nikako ne sme otkriti, jer u suprotnom klijent može na osnovu algoritma potpisa da falsifikuje korisničke informacije o autentifikaciji.
3. Dodavanje JWT provere u Swagger
Za backend programere, kako dodati JWT proveru u Swagger (integrisan sa Knife4j za uljepšavanje)?
Prvi korak, pristupite login interfejsu, unesite korisničko ime i lozinku za prijavljivanje, i dobijte JWT koji vraća server.

Drugi korak, prikupite tokenHead i token koje vraća server, unesite ih u Authorize (obratite pažnju da između tokenHead i token postoji razmak) kako biste završili autentifikaciju prijavljivanja.

Treći korak, kada ponovo tražite pristup drugim interfejsima, Swagger će automatski poslati Authorization kao zaglavlje zahteva serveru.

Četvrti korak, nakon što primi ovaj zahtev, server će proveriti JWT kroz filter JwtAuthenticationTokenFilter.

Time je čitav tok povezan, savršeno!
4. Rezime
Sveukupno gledano, korišćenje JWT-a za rešavanje cross-domain autentifikacije u projektima sa razdvojenim frontendom i backendom je veoma glatko. To je pre svega zahvaljujući univerzalnosti JSON-a koji je višejezični — i JavaScript i Java ga podržavaju; osim toga, sastav JWT-a je vrlo jednostavan, što ga čini pogodnim za prenos; i još, JWT ne zahteva čuvanje informacija o sesiji (Session) na serveru, pa je lako proširiv.
Naravno, da biste osigurali bezbednost JWT-a, nemojte čuvati osetljive informacije u JWT-u, jer jednom kada se privatni ključ otkrije, JWT se lako može dešifrovati na klijentskoj strani; ako je moguće, koristite HTTPS protokol.
Referentni linkovi:
Ruan Yifeng: https://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html
Chunxiaqiudongguo: https://segmentfault.com/a/1190000012557493
Jiangnan Yidianyu: https://cloud.tencent.com/developer/article/1612175
Dearmadman: https://www.jianshu.com/p/576dbf44b2ae
mcarozheng: http://www.macrozheng.com/
Putanja izvornog koda:
https://github.com/itwanger/coding-more

Ništa me ne zadržava — osim cilja; ma koliko na obali bilo ruža, zelene senke i mirnih luka, ja sam nevezani čamac.
