Setelah beberapa kali mencari tutorial tentang otentikasi aplikasi web Spring Boot dengan menggunakan JWT yang mudah dipahami akhirnya saya menemukan artikel berbahasa Inggris tapi sangat mudah dipahami dan diikuti, artikel tersbut berada disini, dengan judul "Spring Boot Token based Authentication with Spring Security & JWT".
Untuk memudahkan orang-orang yang terbiasa membaca artikel dalam bahasa indonesia (termasuk saya sendiri), artikel ini saya buat dan susun ulang (artikel aslinya tidak tertulis dengan runtut dan dapat membuat pemula bingung dengan berbagai error yang muncul) supaya lebih mudah untuk diikuti dan dapat di gunakan bersama.
Applikasi yang akan kita buat adalah aplikasi web yang setiap endpoint-nya hanya bisa di akses oleh role tertentu.
1. Tools Yang Diperlukan
- IntelliJ Idea text editor.
- Spring Assistant Plugin.
- Postman.
- PostgreSQL + DBeaver.
- Min Java 8
- Spring Boot 2.1.8 (dengan Spring Security, Spring Web, Spring Data JPA).
- jjwt 0.9.1.
- Maven 3.6.1.
2. Hasil Akhir
Methods | Urls | Actions |
---|---|---|
POST | /api/auth/signup | signup new account |
POST | /api/auth/signin | login an account |
GET | /api/test/all | retrieve public content |
GET | /api/test/user | access User’s content |
GET | /api/test/mod | access Moderator’s content |
GET | /api/test/admin | access Admin’s content |
2.1. Signup
http://localhost:8282/api/auth/signup
![]() |
User signup sebagai user. |
![]() |
User signup dengan multiple roles, user dan moderator. |
2.2. Signin
http://localhost:8282/api/auth/signin
![]() |
User login dengan menukan username dan password dengan bearer token. |
2.3. Public Access (Anonim)
http://localhost:8282/api/test/all
Melakukan akses pada public endpoint (tanpa token). |
2.4. User Access Biasa (Authenticated)
http://localhost:8282/api/test/user
![]() |
Akses tanpa token dan ditolak dengan 401. |
![]() |
Akses di terima dengan menyertakan Bearer token dari hasil signin. |
2.5. User Moderator (Authenticated)
http://localhost:8282/api/test/mod
Gambar sama saja dengan 2.4 diatas, hanya beda text response.
3.6. User Admin (Authenticated)
http://localhost:8282/api/test/admin
Gambar sama saja dengan 2.4 diatas, hanya beda text response.
3.7. Data Tersimpan (PostgreSQL)
Pastikan data sudah terisi di table roles sebelumnya.INSERT INTO roles(name) VALUES('ROLE_USER'); INSERT INTO roles(name) VALUES('ROLE_MODERATOR'); INSERT INTO roles(name) VALUES('ROLE_ADMIN');
4. Sekilas Info
- WebSecurityConfigurerAdapter merupakan bagian utama dari implementasi Spring security. Dia menyediakan konfigurasi HttpSecurity untuk mengatur cors, csrf, session management, rules untuk dapat melindungi berbagai sumberdaya/informasi di aplikasi kita. Kita bisa meng-extend dan meng-customize konfigurasi bawaannya yang terdiri dari element-element dibawah ini.
- UserDetailsService merupakan sebuah interface yang memiliki sebuah method untuk mengambil User berdasarkan username dan mengembalikan objek UserDetails yang mana Spring Security akan menggunaknnya untuk melakukan authentication dan validation.
- UserDetails berisi informasi yang diperukan seperti: username, password, authorities, untuk digunakan sebagai Authentication object.
- UsernamePasswordAuthenticationToken untuk mendapatkan username dan password dari request yang dikirim oleh form login , AuthenticationManager akan selanjutnya menggunakan ini untuk meng-authenticate akun yang hendak login.
- AuthenticationManager memiliki sebuah DaoAuthenticationProvider (dibantu oleh UserDetailsService & PasswordEncoder) untuk memvalidasi object UsernamePasswordAuthenticationToken . Jika baerhasil, AuthenticationManager akan mengembalikan data dari object Authentication (termasuk didalamnya granted authorities).
- OncePerRequestFilter melakukan sebuah single execution untuk setiap request pada API. Ini memberikan sebuah method bernama doFilterInternal() method yang akan mengimplementasikan proses parsing & validasi JWT, mengambil User details (menggunakan UserDetailsService), mengecek Authorizaion (menggunakan UsernamePasswordAuthenticationToken).
- AuthenticationEntryPoint untuk menangkap error pada saat authentication.
- Repository terdiri dari UserRepository & RoleRepository untuk bekerja dengan Database, akan di import dan digunakan di Controller.
- Controller menerima & menangani request setelah request difilter oleh OncePerRequestFilter.
- AuthController menangani requests di proses signup dan login.
- TestController kita gunakan untuk mengecek dan menguji hak akses.
4.2. Implementasi Pada Tutorial
- WebSecurityConfig extends WebSecurityConfigurerAdapter
- UserDetailsServiceImpl implements UserDetailsService
- UserDetailsImpl implements UserDetails
- AuthEntryPointJwt implements AuthenticationEntryPoint
- AuthTokenFilter extends OncePerRequestFilter
- JwtUtils provides methods for generating, parsing, validating JWT
- controllers handle signup/login requests & authorized requests.
- AuthController: @PostMapping(‘/signin’), @PostMapping(‘/signup’)
- TestController: @GetMapping(‘/api/test/all’), @GetMapping(‘/api/test/[role]’)
- Repository dengan interfaces yang extend Spring Data JPA JpaRepository untuk interaksi denganDatabase PostgreSQL.
- UserRepository extends JpaRepository<User, Long>
- RoleRepository extends JpaRepository<Role, Long>
- Models terdiri dari dua buah models, satu untuk otentikasi yaitu User & satu untuk otorisasi yaitu Role. Mereka memiliki hubungan many-to-many via tabel user_roles.
- User: id, username, email, password, roles
- Role: id, name
- Payloads merupakan classes untuk menampung data Request and Response.
5. Buat Applikasi Spring Boot
- Spring Data JPA
- Spring Web
- Spring Security
- jjwt (Tidak tersedia di Spring Initialzr maka tambahkan kemudian)
- PostgreSQL Driver
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>jakarta.validation</groupId> <artifactId>jakarta.validation-api</artifactId> <version>2.0.2</version> </dependency> </dependencies>
5.2. Struktur Project
server.port=8282 spring.datasource.url= jdbc:postgresql://localhost:5432/roomeo_db spring.datasource.username= postgres spring.datasource.password= postgres spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation= true spring.jpa.properties.hibernate.dialect= org.hibernate.dialect.PostgreSQLDialect # Hibernate ddl auto (create, create-drop, validate, update) spring.jpa.hibernate.ddl-auto= update # App Properties roomeo.app.jwtSecret= 123abc123abc123 roomeo.app.jwtExpirationMs= 86400000
6. Model Entity
6.1. ERole.java (Enum)
package id.beanary.roomeo_be.models; public enum ERole { ROLE_USER, ROLE_MODERATOR, ROLE_ADMIN }
6.2. User.java (Class)
package id.beanary.roomeo_be.models; import javax.persistence.*; import javax.validation.constraints.Email; import javax.validation.constraints.NotBlank; import javax.validation.constraints.Size; import java.util.HashSet; import java.util.Set; @Entity @Table( name = "users", uniqueConstraints = { @UniqueConstraint(columnNames = "username"), @UniqueConstraint(columnNames = "email") }) public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NotBlank @Size(max = 20) private String username; @NotBlank @Size(max = 50) @Email private String email; @NotBlank @Size(max = 120) private String password; @ManyToMany(fetch = FetchType.LAZY) @JoinTable( name = "user_roles", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "role_id")) private Set<Role> roles = new HashSet<>(); public User() { } public User(String username, String email, String password) { this.username = username; this.email = email; this.password = password; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } public Set<Role> getRoles() { return roles; } public void setRoles(Set<Role> roles) { this.roles = roles; } }
6.3. Role.java (Class)
package id.beanary.roomeo_be.models; import javax.persistence.*; @Entity @Table(name = "roles") public class Role { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; @Enumerated(EnumType.STRING) @Column(length = 20) private ERole name; public Role() { } public Role(ERole name) { this.name = name; } public Integer getId() { return id; } public void setId(Integer id) { this.id = id; } public ERole getName() { return name; } public void setName(ERole name) { this.name = name; } }
7. Payload
7.1. Request Payload
package id.beanary.roomeo_be.payloads.requests; public class LoginRequest { public String username; public String password; public LoginRequest(String username, String password) { this.username = username; this.password = password; } public void setUsername(String username) { this.username = username; } public void setPassword(String password) { this.password = password; } public String getUsername() { return username; } public String getPassword() { return password; } }
package id.beanary.roomeo_be.payloads.requests; import java.util.List; import java.util.Set; public class SignupRequest { public String username; public String email; public String password; private Set<String> roles; public SignupRequest(String username, String email, String password, Set<String> roles) { this.username = username; this.email = email; this.password = password; this.roles = roles; } public String getUsername() { return username; } public String getEmail() { return email; } public String getPassword() { return password; } public Set<String> getRoles() { return roles; } public void setUsername(String username) { this.username = username; } public void setEmail(String email) { this.email = email; } public void setPassword(String password) { this.password = password; } public void setRoles(Set<String> roles) { this.roles = roles; } }
7.2. Reponse Payload
package id.beanary.roomeo_be.payloads.responses; import id.beanary.roomeo_be.models.Role; import java.util.HashSet; import java.util.List; import java.util.Set; public class JwtResponse { public String token; public String type = "Bearer"; public Long id; public String username; public String email; private List<String> roles; public JwtResponse(String token, Long id, String username, String email, List<String> roles) { this.token = token; this.id = id; this.username = username; this.email = email; this.roles = roles; } public String getToken() { return token; } public Long getId() { return id; } public String getUsername() { return username; } public String getEmail() { return email; } public List<String> getRoles() { return roles; } public String getType() { return type; } public void setToken(String token) { this.token = token; } public void setId(Long id) { this.id = id; } public void setUsername(String username) { this.username = username; } public void setEmail(String email) { this.email = email; } public void setRoles(List<String> roles) { this.roles = roles; } public void setType(String type) {this.type = type;} }
package id.beanary.roomeo_be.payloads.responses; public class MessageResponse { public String message; public MessageResponse(String message) { this.message = message; } public void setMessage(String message) { this.message = message; } public String getMessage() { return message; } }
8. JPA Repository
8.1. RoleRepository.java
package id.beanary.roomeo_be.repositories; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import id.beanary.roomeo_be.models.ERole; import id.beanary.roomeo_be.models.Role; @Repository public interface RoleRepository extends JpaRepository<Role, Long> { Optional<Role> findByName(ERole name); }
8.2. UserRepository.java
package id.beanary.roomeo_be.repositories; import java.util.Optional; import id.beanary.roomeo_be.models.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface UserRepository extends JpaRepository<User, Long> { Optional<User> findByUsername(String username); Boolean existsByUsername(String username); Boolean existsByEmail(String email); }
9. Security Service
9.1. UserDetailsImpl.java
package id.beanary.roomeo_be.security.services; import java.util.Collection; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; import id.beanary.roomeo_be.models.User; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import com.fasterxml.jackson.annotation.JsonIgnore; public class UserDetailsImpl implements UserDetails { private static final long serialVersionUID = 1L; private Long id; private String username; private String email; @JsonIgnore private String password; private Collection<? extends GrantedAuthority> authorities; public UserDetailsImpl(Long id, String username, String email, String password, Collection<? extends GrantedAuthority> authorities) { this.id = id; this.username = username; this.email = email; this.password = password; this.authorities = authorities; } public static UserDetailsImpl build(User user) { List<GrantedAuthority> authorities = user.getRoles().stream() .map(role -> new SimpleGrantedAuthority(role.getName().name())) .collect(Collectors.toList()); return new UserDetailsImpl( user.getId(), user.getUsername(), user.getEmail(), user.getPassword(), authorities); } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; } public Long getId() { return id; } public String getEmail() { return email; } @Override public String getPassword() { return password; } @Override public String getUsername() { return username; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; UserDetailsImpl user = (UserDetailsImpl) o; return Objects.equals(id, user.id); } }
9.2. UserDetailsService.java (Tidak dipakai)
package id.beanary.roomeo_be.security.services; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UsernameNotFoundException; public interface UserDetailsService { UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; }
9.3. UserDetailsServiceImpl.java
package id.beanary.roomeo_be.security.services; import id.beanary.roomeo_be.models.User; import id.beanary.roomeo_be.repositories.UserRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired UserRepository userRepository; @Override @Transactional public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("User Not Found with username: " + username)); return UserDetailsImpl.build(user); } }
9.4. JWT Utilities
package id.beanary.roomeo_be.security.jwt; import java.io.IOException; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; @Component public class AuthEntryPointJwt implements AuthenticationEntryPoint { private static final Logger logger = LoggerFactory.getLogger(AuthEntryPointJwt.class); @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { logger.error("Unauthorized error: {}", authException.getMessage()); response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Error: Unauthorized"); } }
package id.beanary.roomeo_be.security.jwt; import java.io.IOException; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import id.beanary.roomeo_be.security.services.UserDetailsServiceImpl; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; public class AuthTokenFilter extends OncePerRequestFilter { @Autowired private JwtUtils jwtUtils; @Autowired private UserDetailsServiceImpl userDetailsService; private static final Logger logger = LoggerFactory.getLogger(AuthTokenFilter.class); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { String jwt = parseJwt(request); if (jwt != null && jwtUtils.validateJwtToken(jwt)) { String username = jwtUtils.getUserNameFromJwtToken(jwt); UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (Exception e) { logger.error("Cannot set user authentication: {}", e); } filterChain.doFilter(request, response); } private String parseJwt(HttpServletRequest request) { String headerAuth = request.getHeader("Authorization"); if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) { return headerAuth.substring(7, headerAuth.length()); } return null; } }
package id.beanary.roomeo_be.security.jwt; import java.util.Date; import id.beanary.roomeo_be.security.services.UserDetailsImpl; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Component; import io.jsonwebtoken.*; @Component public class JwtUtils { private static final Logger logger = LoggerFactory.getLogger(JwtUtils.class); @Value("${roomeo.app.jwtSecret}") private String jwtSecret; @Value("${roomeo.app.jwtExpirationMs}") private int jwtExpirationMs; public String generateJwtToken(Authentication authentication) { UserDetailsImpl userPrincipal = (UserDetailsImpl) authentication.getPrincipal(); return Jwts.builder() .setSubject((userPrincipal.getUsername())) .setIssuedAt(new Date()) .setExpiration(new Date((new Date()).getTime() + jwtExpirationMs)) .signWith(SignatureAlgorithm.HS512, jwtSecret) .compact(); } public String getUserNameFromJwtToken(String token) { return Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token).getBody().getSubject(); } public boolean validateJwtToken(String authToken) { try { Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken); return true; } catch (SignatureException e) { logger.error("Invalid JWT signature: {}", e.getMessage()); } catch (MalformedJwtException e) { logger.error("Invalid JWT token: {}", e.getMessage()); } catch (ExpiredJwtException e) { logger.error("JWT token is expired: {}", e.getMessage()); } catch (UnsupportedJwtException e) { logger.error("JWT token is unsupported: {}", e.getMessage()); } catch (IllegalArgumentException e) { logger.error("JWT claims string is empty: {}", e.getMessage()); } return false; } }
9.5. Konfigurasi Utama
package id.beanary.roomeo_be.security; import id.beanary.roomeo_be.security.jwt.AuthEntryPointJwt; import id.beanary.roomeo_be.security.jwt.AuthTokenFilter; import id.beanary.roomeo_be.security.services.UserDetailsServiceImpl; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; 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.configuration.WebSecurityConfigurerAdapter; 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.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity( // securedEnabled = true, // jsr250Enabled = true, prePostEnabled = true) public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired UserDetailsServiceImpl userDetailsService; @Autowired private AuthEntryPointJwt unauthorizedHandler; @Bean public AuthTokenFilter authenticationJwtTokenFilter() { return new AuthTokenFilter(); } @Override public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception { authenticationManagerBuilder.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Override protected void configure(HttpSecurity http) throws Exception { http.cors().and().csrf().disable() .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() .authorizeRequests().antMatchers("/api/auth/**").permitAll() .antMatchers("/api/test/**").permitAll() .anyRequest().authenticated(); http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class); } }
Comments
Post a Comment