I'm trying to make a blog w/ spring boot. I am using @Validated and groupings for certain requests. For Example hitting my signup route, I validate the email using @NotBlank and @Email on my User model
Controller:
package com.vweinert.fedditbackend.controllers;
import com.vweinert.fedditbackend.security.jwt.AuthEntryPointJwt;
import org.modelmapper.ModelMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.vweinert.fedditbackend.entities.User;
import com.vweinert.fedditbackend.request.auth.LoginRequest;
import com.vweinert.fedditbackend.request.auth.SignupRequest;
import com.vweinert.fedditbackend.dto.AuthDto;
import com.vweinert.fedditbackend.service.inter.UserService;
@CrossOrigin(origins = "*", maxAge = 3600)
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserService userService;
private final ModelMapper modelMapper;
private static final Logger logger = LoggerFactory.getLogger(AuthController.class);
public AuthController(UserService userService, ModelMapper modelMapper) {
this.userService = userService;
this.modelMapper = modelMapper;
logger.debug("Auth controller initialized");
}
@PostMapping("/signin")
public ResponseEntity<?> loginUser(@Validated(LoginRequest.class) @RequestBody User loginRequest) {
try {
logger.debug("logging in user {},",loginRequest);
User user = userService.sigInUser(loginRequest);
AuthDto authDto = modelMapper.map(user, AuthDto.class);
return ResponseEntity.ok(authDto);
} catch (Exception e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
@PostMapping("/signup")
public ResponseEntity<?> registerUser(@Validated(SignupRequest.class) @RequestBody User signUpRequest) {
try {
logger.debug("signing up user {},", signUpRequest);
User user = userService.registerUser(signUpRequest);
AuthDto authDto = modelMapper.map(user, AuthDto.class);
return ResponseEntity.ok(authDto);
} catch (Exception e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
}
Model:
package com.vweinert.fedditbackend.entities;
import java.time.LocalDateTime;
import java.util.Set;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToMany;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.JoinTable;
import jakarta.persistence.Transient;
import jakarta.persistence.FetchType;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.hibernate.annotations.CreationTimestamp;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import com.vweinert.fedditbackend.request.auth.LoginRequest;
import com.vweinert.fedditbackend.request.auth.SignupRequest;
@Entity
@Table(name="users")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Column(nullable=false,unique = true)
@NotBlank(groups = {SignupRequest.class}, message = "email is blank")
@Email(groups = {SignupRequest.class}, message = "invalid email")
private String email;
@Column(nullable=false,updatable = false,unique = true)
@NotBlank(groups = {LoginRequest.class, SignupRequest.class},message = "username is blank")
@Size(min = 8, max = 32, groups = {LoginRequest.class, SignupRequest.class}, message = "username must be between 8 and 32 characters")
private String username;
@Column(nullable=false)
@NotBlank(groups = {LoginRequest.class, SignupRequest.class},message = "Missing password")
@Size(min = 8, max = 32, groups = {LoginRequest.class, SignupRequest.class}, message = "password must be between 8 and 32 characters")
private String password;
@Column(columnDefinition = "text")
private String about;
@Column(nullable=false,updatable = false)
@CreationTimestamp
private LocalDateTime createdAt;
private LocalDateTime passwordChangedAt;
private LocalDateTime aboutChangedAt;
@Column(nullable = false)
@Builder.Default
private Boolean deleted = false;
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable( name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id"))
private Set<Role> roles;
@OneToMany(mappedBy = "user",fetch = FetchType.LAZY)
private Set<Post> posts;
@OneToMany(mappedBy = "user",fetch = FetchType.LAZY)
private Set<Comment> comments;
@Transient
@JsonInclude()
private String jwt;
public User(String username, String email, String password){
this.username = username;
this.email = email;
this.password = password;
}
}
Signup request interface for the @Validated groups
package com.vweinert.fedditbackend.request.auth;
public interface SignupRequest {
}
Web Security Config:
package com.vweinert.fedditbackend.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
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.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import com.vweinert.fedditbackend.security.jwt.AuthEntryPointJwt;
import com.vweinert.fedditbackend.security.jwt.AuthTokenFilter;
import com.vweinert.fedditbackend.security.services.UserDetailsServiceImpl;
import com.vweinert.fedditbackend.security.jwt.JwtUtils;
@Configuration
@EnableGlobalMethodSecurity(
prePostEnabled = true)
public class WebSecurityConfig {
private final UserDetailsServiceImpl userDetailsService;
private final AuthEntryPointJwt unauthorizedHandler;
private final JwtUtils jwtUtils;
public WebSecurityConfig(JwtUtils jwtUtils, UserDetailsServiceImpl userDetailsService, AuthEntryPointJwt unauthorizedHandler) {
this.jwtUtils = jwtUtils;
this.userDetailsService = userDetailsService;
this.unauthorizedHandler = unauthorizedHandler;
}
@Bean
public AuthTokenFilter authenticationJwtTokenFilter() {
return new AuthTokenFilter(jwtUtils,userDetailsService);
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable()
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeHttpRequests((requests) -> requests
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/home/**").permitAll()
.anyRequest().authenticated()
);
http.authenticationProvider(authenticationProvider());
http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
Authentication entry Point:
package com.vweinert.fedditbackend.security.jwt;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.databind.ObjectMapper;
@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.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
final Map<String, Object> body = new HashMap<>();
body.put("status", HttpServletResponse.SC_UNAUTHORIZED);
body.put("error", "Unauthorized");
body.put("message", authException.getMessage());
body.put("path", request.getServletPath());
final ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(response.getOutputStream(), body);
}
}
Currently I am getting an AuthenticationException going through my commence method that implements the AuthenticationEntryPoint class. I would like to have custom exceptions that simply return the message attached to my @Email annotation, though I'm really not sure how to do this.
When I do a postman call with this (with no authorization and an intentionally invalid email):
{
"email":"emailssss",
"username":"emailsssssssss",
"password":"emailly123theemail"
}
I get:
{
"path": "/error",
"error": "Unauthorized",
"message": "Full authentication is required to access this resource",
"status": 401
}
and in the console I get:
2023-01-04T14:37:25.768-06:00 WARN 36022 --- [nio-8080-exec-1] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public org.springframework.http.ResponseEntity<?> com.vweinert.fedditbackend.controllers.AuthController.registerUser(com.vweinert.fedditbackend.entities.User): [Field error in object 'user' on field 'email': rejected value [emailssss]; codes [Email.user.email,Email.email,Email.java.lang.String,Email]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.email,email]; arguments []; default message [email],[Ljakarta.validation.constraints.Pattern$Flag;@49be6e2e,.*]; default message [invalid email]] ]
2023-01-04T14:37:25.774-06:00 ERROR 36022 --- [nio-8080-exec-1] c.v.f.security.jwt.AuthEntryPointJwt : Unauthorized error: Full authentication is required to access this resource
I would like to get something like:
{
"message": "invalid email"
}
CodePudding user response:
added
.requestMatchers("/error").permitAll()
To my filterChain in my WebSecurityConfig file. I did not know that when @Validated fails to validate it redirects to /error