Issue
Be default spring boot utilizes the HttpServletRequest
that accepts value from the client end defaults to "/login" route.
If I I want to create a custom authentication system with options like:
- signin endpoint like: "/api/v1/auth/sign" that will accept email and password
- rather than creating CustomAuthFilter as shown by many videos in YT create a method in auth controller that handles sending back the jwt tokens.
Now I already know that for changing the default login route I need:
.formLogin().loginProcessingUrl("/api/v1/login")
But what about the next part?
Do I need to create objects like SignInRequest
and SignInResponse
?
If so will the client applications need to map data in accordance to the SignInRequest
and SignInResponse
?
This is my Signup
service:
@Override
public User signup(User user) {
String encodedPassword = passwordEncoder.encode(user.getPassword());
user.setPassword(encodedPassword);
return authRepository.save(user);
}
I want to create a similar service for signin like:
@Override
public User signin(String email, String password) {
// somehow do login and return the user with access and refresh tokens?
}
Even if I create a SigninRequest
object the client application will always send the email and password right?
Since I haven't worked with complex backends I have very limited idea on how to solve this issues.
Any insight or resource would be helpful thanks.
My current attemptAuthentication
method:
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
StringBuffer sb = new StringBuffer();
BufferedReader reader = null;
String content = "";
String email = "";
String password = "";
try {
reader = request.getReader();
char[] buffer = new char[1024];
int read;
while ((read = reader.read(buffer, 0, buffer.length)) != -1) {
sb.append(buffer, 0, read);
}
content = sb.toString();
Map<String, String> map = new ObjectMapper().readValue(content, Map.class);
email = map.get("email");
password = map.get("password");
} catch (IOException e) {
e.printStackTrace();
} finally {
if (reader != null) {
try {
reader.close();
} catch (IOException ex) {
try {
throw ex;
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(email, password);
return authenticationManager.authenticate(authenticationToken);
}
Solution
Spring supports many sophisticated custom authentication methods. A full overview is available in the spring security documentation:
Spring Security Docs - Authentication
From your question I conclude that you want to stick to some sort of user/password authentication. This is described in the docs here:
Spring Security Docs - User/Password Authentication
You basically have three options:
- Form Login: Here, a dynamically generated HTML page is presented to the user, where he can enter user (in your case the email address) and password.
- Basic Authentication: Here is no need for a separate HTML page. Instead the browser presents a popup dialog directly, where the user enters his credentials.
- Digest Authentication: Rather uncommon and not recommended by spring.org.
Again, from your question I conclude that you want to stick to form login. Spring offers many options for this alternative, which are extensively covered in the above docs.
Another good starting point is the following popular tutorial:
Baeldung Tutorial about Spring Security Form Login
Apart from the mere implementation aspects, here are of course a lot of security considerations involved in the question of authentication methods: User/password authentication is considered a rather insecure form of authentication, as it requires the exchange of a secret between the user and the server. More secure forms are e.g. use one time passwords or certificates (e.g. SSL client certificated which are handled by the Browser). As a starting point for further reading, I can suggest:
Rising Stack - Web Authentication Methods Explained
Port Swigger - Authentication vulnerabilities
For more clarification, I have added here some example code how to do a form login with a react client. This example is based on the reactive WebTestClient in the Spring Framework.
A form login involves the following steps:
- A client sends a request (GET or POST) with the wanted URI (could be a static or dynamic HTML page or REST resource).
- When the server identifies that the requested URI requires authentication he responds with HTTP redirect status code (302) which points to the login page.
- Now the client initiates the login sequence by sending a GET request to the login page. Whereas steps 1 and 2 are optional and may be omitted, this step is crucial, as it initiates a session on the server.
- When receiving the request to the login page, the server creates a new HTTP session, a CSRF token (see Spring doc), and it generates the dynamic login page.
- The client replies with a POST request which includes user/password and the CSRF token in the body and the session id in the reuest header (e.g. as cookie).
- When everything is correct and steps 1 and 2 where not omitted, the server responds with a redirect to the originally requested resource from step 1 (or to the default landing page, if step 1 and 2 where omitted).
- Now the client can request the wanted resource again. For all following requests it must include the session id in the request header.
Here now a test client implementation in Java which performs this sequence on the client side:
public class WebTestClientUtil {
public static class ResponseHolder {
public String baseUrl;
public HttpStatus status;
public String body;
public String sessionId;
public String csrfToken;
public String location;
public ResponseHolder(boolean sslEnabled, int port) {
this.baseUrl = getBaseUrl(sslEnabled, port);
}
}
public enum SessionIdResolutionMethod {
HEADER,
COOKIE,
URL
}
/**
* Determine the server base url
*
* @param port of the server to connect to
* @return the server url
*/
static public String getBaseUrl(boolean sslEnabled, int port) {
return "http" + (sslEnabled ? "s" : "") + "://localhost:" + port + "/";
}
/**
* Build a client for testing
*
* @param ref the response holder to be used for further communication
* @return the client
*/
public static WebTestClient buildClient(ResponseHolder ref) {
return WebTestClient
.bindToServer()
.baseUrl(ref.baseUrl)
.responseTimeout(Duration.ofMinutes(10)) // uncomment for debugging
.build();
}
/**
* Perform a form login and return a response spec to formulate expectations about the result
*
* @param client the client to be used for connecting to the server
* @param uri for the request
* @param username for authentication
* @param password for authentication
* @param ref container for the server response
* @return the response spec
*/
public static WebTestClient.ResponseSpec performLoginSequence(
WebTestClient client,
String uri,
String username,
String password,
ResponseHolder ref) {
return performLoginSequence(client, uri, username, password, ref, SessionIdResolutionMethod.HEADER);
}
/**
* Perform a form login and return a response spec to formulate expectations about the result
*
* @param client the client to be used for connecting to the server
* @param uri for the request
* @param username for authentication
* @param password for authentication
* @param ref container for the server response
* @param sessionIdResolutionMethod true if cookies shall be used for session id resolution
* @return the response spec
*/
public static WebTestClient.ResponseSpec performLoginSequence(
WebTestClient client,
String uri,
String username,
String password,
ResponseHolder ref,
SessionIdResolutionMethod sessionIdResolutionMethod) {
System.out.println();
System.out.println("----------------------------------------------------------------------");
System.out.println("New login sequence initiated for user " + username + " with password " + password + "...");
System.out.println("----------------------------------------------------------------------");
// Send api request
evaluateResponse(client.get().uri(uri).exchange(), ref, sessionIdResolutionMethod)
.expectStatus().is3xxRedirection()
.expectHeader().location(ref.baseUrl + "login");
// Send login request
evaluateResponse(getRequestSpec(client, "login", ref, sessionIdResolutionMethod)
.accept(MediaType.TEXT_HTML)
.acceptCharset(StandardCharsets.UTF_8)
.exchange(), ref, sessionIdResolutionMethod)
.expectStatus().isOk();
assertThat(ref.body).contains("name=\"username\"").contains("name=\"password\"").contains("name=\"_csrf\"");
assertThat(ref.csrfToken).isNotNull();
// Send login details
WebTestClient.ResponseSpec response =
evaluateResponse(postRequestSpec(client, "login", ref, sessionIdResolutionMethod)
.body(BodyInserters
.fromFormData("_csrf", ref.csrfToken)
.with("username", username)
.with("password", password))
.exchange(), ref, sessionIdResolutionMethod);
// In case of errors abort login process
System.out.println("Login sequence completed...");
System.out.println("----------------------------------------------------------------------");
if (ref.status != HttpStatus.FOUND) return response;
if (!ref.location.equals(ref.baseUrl + uri)) return response;
// After successful login continue with redirect
System.out.println("Continuing with original request " + uri + "...");
return evaluateRedirect(client, ref, sessionIdResolutionMethod);
}
public static WebTestClient.ResponseSpec evaluateResponse(WebTestClient.ResponseSpec response, ResponseHolder r) {
return evaluateResponse(response, r, SessionIdResolutionMethod.HEADER);
}
public static WebTestClient.ResponseSpec evaluateResponse(
WebTestClient.ResponseSpec response,
ResponseHolder r,
SessionIdResolutionMethod sessionIdResolutionMethod) {
ExchangeResult result = response
.expectBody(String.class).consumeWith(v -> r.body = v.getResponseBody())
.returnResult();
r.status = result.getStatus();
System.out.println();
System.out.println("Response for request " + result.getUrl());
System.out.println(" HTTP status = " + r.status);
String sessionId = null;
switch (sessionIdResolutionMethod) {
case HEADER, URL -> sessionId = result.getResponseHeaders().getFirst(ResponseHeaderFilter.SESSION_ID_HEADER_NAME);
case COOKIE -> {
ResponseCookie sessionIdCookie = result.getResponseCookies().getFirst("SESSION");
if (sessionIdCookie != null) sessionId = sessionIdCookie.getValue();
}
}
if (sessionId == null) {
System.out.println(" Session id not set in " + sessionIdResolutionMethod +
" - continuing with old (" + r.sessionId + ")");
} else {
r.sessionId = sessionId;
System.out.println(" Session id --> " + r.sessionId);
}
if (r.body == null) r.body = "";
String csrfToken = getCsrfToken(r.body);
if (csrfToken != null) r.csrfToken = csrfToken;
csrfToken = result.getResponseHeaders().getFirst(ResponseHeaderFilter.CSRF_HEADER_NAME);
if (csrfToken != null) {
csrfToken = result.getResponseHeaders().getFirst(csrfToken);
if (csrfToken != null) r.csrfToken = csrfToken;
}
System.out.println(" CSRF token = " + csrfToken + " --> " + r.csrfToken);
System.out.println(" Response Headers:");
for (Map.Entry<String, List<String>> header : result.getResponseHeaders().entrySet()) {
System.out.println(" " +
header.getKey() + ": " +
String.join("; ", header.getValue()));
}
r.location = result.getResponseHeaders().getFirst("Location");
System.out.println(" Body:\n" + r.body);
return response;
}
public static WebTestClient.RequestHeadersSpec<?> getRequestSpec(
WebTestClient client,
String uri,
ResponseHolder ref,
SessionIdResolutionMethod sessionIdResolutionMethod) {
switch (sessionIdResolutionMethod) {
case HEADER -> {
WebTestClient.RequestHeadersSpec<?> request = client.get().uri(uri);
return request.header(ResponseHeaderFilter.SESSION_ID_HEADER_NAME, ref.sessionId);
}
case COOKIE -> {
WebTestClient.RequestHeadersSpec<?> request = client.get().uri(uri);
return request.cookie("SESSION", ref.sessionId);
}
case URL -> {
return client.get().uri(uri +
(uri.contains("?") ? "&" : "?") +
"xAuthToken=" +
new String(Base64.getEncoder().encode(ref.sessionId.getBytes())));
}
}
throw new IllegalArgumentException("Invalid session id resolution method: " + sessionIdResolutionMethod);
}
public static WebTestClient.RequestBodySpec postRequestSpec(
WebTestClient client,
String uri,
ResponseHolder ref,
SessionIdResolutionMethod sessionIdResolutionMethod) {
switch (sessionIdResolutionMethod) {
case HEADER -> {
WebTestClient.RequestBodySpec request = client.post().uri(uri);
return request.header(ResponseHeaderFilter.SESSION_ID_HEADER_NAME, ref.sessionId);
}
case COOKIE -> {
WebTestClient.RequestBodySpec request = client.post().uri(uri);
return request.cookie("SESSION", ref.sessionId);
}
case URL -> {
return client.post().uri(uri +
(uri.contains("?") ? "&" : "?") +
"xAuthToken=" +
new String(Base64.getEncoder().encode(ref.sessionId.getBytes())));
}
}
throw new IllegalArgumentException("Invalid session id resolution method: " + sessionIdResolutionMethod);
}
public static String getCsrfToken(String body) {
int pos = body.indexOf("name=\"_csrf\"");
if (pos < 0) return null;
int start = body.indexOf("value=\"", pos) + 7;
if (start < 0) return null;
int end = body.indexOf("\"", start);
if (end < 0) return null;
return body.substring(start, end);
}
@SuppressWarnings("UnusedReturnValue")
public static WebTestClient.ResponseSpec evaluateRedirect(WebTestClient client, ResponseHolder r) {
return evaluateRedirect(client, r, SessionIdResolutionMethod.HEADER);
}
public static WebTestClient.ResponseSpec evaluateRedirect(WebTestClient client, ResponseHolder r, SessionIdResolutionMethod sessionIdResolutionMethod) {
return evaluateResponse(getRequestSpec(client, r.location, r, sessionIdResolutionMethod)
.accept(MediaType.APPLICATION_JSON)
.acceptCharset(StandardCharsets.UTF_8)
.exchange(), r, sessionIdResolutionMethod);
}
public static void performLogout(WebTestClient client, ResponseHolder r) {
performLogout(client, r, SessionIdResolutionMethod.HEADER);
}
public static void performLogout(WebTestClient client, ResponseHolder r, SessionIdResolutionMethod sessionIdResolutionMethod) {
evaluateResponse(postRequestSpec(client, r.baseUrl + "logout", r, sessionIdResolutionMethod)
.acceptCharset(StandardCharsets.UTF_8)
.exchange(), r, sessionIdResolutionMethod)
.expectStatus().is3xxRedirection();
assertThat(r.location).isEqualTo(r.baseUrl + "login?logout");
}
}
Answered By - Udo Knop
Answer Checked By - Gilberto Lyons (JavaFixing Admin)