Uwierzytelnianie i autoryzacja z użyciem JWT, Angulara i Spring Boota [część 1]

Uwierzytelnianie i autoryzacja z użyciem JWT, Angulara i Spring Boota [część 1]

Z tego wpisu dowiesz się w jaki sposób zaprojektować uwierzytelnianie (autentykacje) i autoryzację z użyciem tokena JWT i Angulara. Jest to polskie opracowanie oryginalnego wpisu który znajduje się tutaj: https://bezkoder.com/angular-jwt-authentication/. Odnośnie samego tokena JWT odsyłam do wpisu: https://javaleader.pl/2019/10/31/jwt-json-web-token-w-spring-boot/. Tworzymy nowy projekt Spring Boota – plik pom.xml – niezbędne zależności:

<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>org.postgresql</groupId>
		<artifactId>postgresql</artifactId>
		<scope>runtime</scope>
	</dependency>
 
	<dependency>
		<groupId>mysql</groupId>
		<artifactId>mysql-connector-java</artifactId>
		<scope>runtime</scope>
	</dependency>
 
	<dependency>
		<groupId>io.jsonwebtoken</groupId>
		<artifactId>jjwt</artifactId>
		<version>0.9.1</version>
	</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>org.projectlombok</groupId>
		<artifactId>lombok</artifactId>
		<version>1.18.12</version>
		<scope>provided</scope>
	</dependency>
 
</dependencies>

Konfiguracja Spring Security:

Klasa WebSecurityConfig:

@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);
	}
}

wyjątki uwierzytelniania rejestrowane są poprzez:

.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()

gdzie unauthorizedHandler:

@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");
	}
}

czyli jeśli użytkownik wpisze błędny login lub hasło to wyświetli się na konsoli:

 c.b.s.security.jwt.AuthEntryPointJwt : Unauthorized error: Bad credentials

oraz zobaczymy komunikat w przeglądarce:

Error: Unauthorized

wpis:

.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()

oznacza, że nie będzie tworzona żadna sesja z użyciem Spring Security – autentykacja odbywać się będzie poprzez wywołanie filtra weryfikującego każde żądanie. Kolejne wpisy oznaczają, że każdy odwiedzający ma dostęp do endpointów:

  • /api/auth/**
  • /api/test/**

inne requesty będą weryfikowane tokenem JWT. Przed wykonaniem domyślnego filtra – UsernamePasswordAuthenticationFilter – który jest odpowiedzialny za odczyt danych z formularza wykonujemy filtr AuthTokenFilter (filtr ten wykonywany jest zawsze przed każdym żądaniem do zasobu, ale przed UsernamePasswordAuthenticationFilter który wykonywany jest tylko kiedy użytkownik wyśle formularz dostępny pod domyślnym adresem /login).

http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);

żeby zweryfikować czy żądanie które jest wysyłane do API zawiera w nagłówku token JWT:

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());
				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;
	}
}

Autentykacja do API:

Użytkownik wysyła formularz na adres ./login który zawiera w żądaniu login i hasło. Żądanie to trafia do kontrolera – /api/auth/signin który zawiera metodę:

@PostMapping("/signin")
public ResponseEntity<?> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {
 
	Authentication authentication = authenticationManager.authenticate(
			new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()));
 
	SecurityContextHolder.getContext().setAuthentication(authentication);
	String jwt = jwtUtils.generateJwtToken(authentication);
 
	UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();		
	List<String> roles = userDetails.getAuthorities().stream()
			.map(item -> item.getAuthority())
			.collect(Collectors.toList());
 
	return ResponseEntity.ok(new JwtResponse(jwt,
               userDetails.getId(), userDetails.getUsername(), userDetails.getEmail(), roles));
}

AuthenticationManager to klasa która używa DaoAuthenticationProvider gdzie przy wykorzystaniu UserDetailsService dokonuje walidacji danych (loginu i hasła) przekazanych do konstruktora klasy UsernamePasswordAuthenticationToken. Autentykacja odbywa się na zasadzie porównania danych przekazanych przez użytkownika z formularza z tymi które zapisane są w bazie danych:

@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);
	}
}

pobierany jest użytkowik z bazy danych – jeżeli istnieje to obiekt klasy UserDetails jest zwracany. W przeciwnym wypadku zwracany jest wyjątek – „user not found„. Dane zawarte w obiekcie UserDetails porównywane są z tymi które użytkownik przekazał. Jeśli te dane są prawidłowe następuje autentykacja użytkownika i zwrócenie tokena JWT który zawiera niezbędne dane – id, username, email, role.

return ResponseEntity.ok(new JwtResponse(jwt,userDetails.getId(),userDetails.getUsername(),userDetails.getEmail(),roles));

gdzie parametr jwt:

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();
}

Autoryzacja do API:

Aby przeglądać zasoby API należy dokonać autentykacji oraz autoryzacji. Zasoby wymagają różnych uprawnień. Kiedy użytkownik wysyła żądanie do zasobu wykonywany jest filtr AuthTokenFilter. Jest to filtr który dziedziczy po klasie OncePerRequestFilter.

public class AuthTokenFilter extends OncePerRequestFilter {
...

Klasa testowa z różnymi zasobami do których dostęp przydzielany jest według uprawnień. Użycie adnotacji @PreAuthorize możliwe jest dzięki zdefiniowaniu w klasie konfiguracyjnej Spring Security adnotacji EnableGlobalMethodSecurity:

@CrossOrigin(origins = "*", maxAge = 3600)
@RestController
@RequestMapping("/api/test")
public class TestController {
	@GetMapping("/all")
	public String allAccess() {
		return "Public Content.";
	}
 
	@GetMapping("/user")
	@PreAuthorize("hasRole('USER') or hasRole('MODERATOR') or hasRole('ADMIN')")
	public String userAccess() {
		return "User Content.";
	}
 
	@GetMapping("/mod")
	@PreAuthorize("hasRole('MODERATOR')")
	public String moderatorAccess() {
		return "Moderator Board.";
	}
 
	@GetMapping("/admin")
	@PreAuthorize("hasRole('ADMIN')")
	public String adminAccess() {
		return "Admin Board.";
	}
}

Filtr AuthTokenFilter sprawdza czy w nagłówku żądania przekazywany jest token JWT – weryfikowana jest również jego prawidłowość:

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());

na podstawie przekazanego tokena JWT wybierany jest login użytkownika. Następnie dane użytkownika m.in. jego rola pobierane są z bazy danych. Użytkownik jest autoryzowany. Jeśli token jest nieprawidłowy wyrzucany jest wyjątek – Cannot set user authentication: {}. Po autoryzacji użytkownika jeśli jego rola nie jest zgodna z polityką która zdefiniowana jest w adnotacjach:

@PreAuthorize("hasRole('MODERATOR')")

dostęp jest do API jest blokowany.

Rejestracja nowego użytkownika:

Kiedy użytkownik chce się zarejestrować w systemie – wysyła request metodą POST na adres /api/auth/signup. Domyślnie jeśli nie ma przekazanej roli wybierana jest rola USER:

@PostMapping("/signup")
public ResponseEntity<?> registerUser(@Valid @RequestBody SignupRequest signUpRequest) {
    if (userRepository.existsByUsername(signUpRequest.getUsername())) {
        return ResponseEntity
                .badRequest()
                .body(new MessageResponse("Error: Username is already taken!"));
    }
 
    if (userRepository.existsByEmail(signUpRequest.getEmail())) {
        return ResponseEntity
                .badRequest()
                .body(new MessageResponse("Error: Email is already in use!"));
    }
 
    // Create new user's account
    User user = new User(signUpRequest.getUsername(), 
                         signUpRequest.getEmail(),
                         encoder.encode(signUpRequest.getPassword()));
 
    Set<String> strRoles = signUpRequest.getRole();
    Set<Role> roles = new HashSet<>();
 
    if (strRoles == null) {
        Role userRole = roleRepository.findByName(ERole.ROLE_USER)
                .orElseThrow(() -> new RuntimeException("Error: Role is not found."));
        roles.add(userRole);
    } else {
        strRoles.forEach(role -> {
            switch (role) {
            case "admin":
                Role adminRole = roleRepository.findByName(ERole.ROLE_ADMIN)
                        .orElseThrow(() -> new RuntimeException("Error: Role is not found."));
                roles.add(adminRole);
                break;
            case "mod":
                Role modRole = roleRepository.findByName(ERole.ROLE_MODERATOR)
                        .orElseThrow(() -> new RuntimeException("Error: Role is not found."));
                roles.add(modRole);
                break;
            default:
                Role userRole = roleRepository.findByName(ERole.ROLE_USER)
                        .orElseThrow(() -> new RuntimeException("Error: Role is not found."));
                roles.add(userRole);
            }
        });
    }
    user.setRoles(roles);
    userRepository.save(user);
 
    return ResponseEntity.ok(new MessageResponse("User registered successfully!"));
}

Zobacz kod na GitHubie i zapisz się na bezpłatny newsletter!

.

1 Comment

  1. Witam ! Piszę własną aplikację w oparciu o Pański tutorial. Pytanie mam więc do Pana takie: z jakiej tablicy pobierane są dane o użytkowniku ? W sensie jego email, bo jest on w mojej aplikacji, Pańskim „username”m. Czy to bezpieczne czytać email z tablicy „login” ? w niej mam zapisane id i email, a w tablicy profile – mam zapisane user_id i podstawowe dane profilowe(first_name, last_name, tel … ). profile.user_id jest powiązany relacyjnie z login.id.
    PS.
    Backend mam napisany w django(python3), bo się tak uparł backendowiec, ale radzi sobie z nim nieźle.

Leave a comment

Your email address will not be published.


*