Skip to content

Commit

Permalink
fix(lnurl-auth): set UserDetails as principal on successful authentic…
Browse files Browse the repository at this point in the history
…ation
  • Loading branch information
theborakompanioni committed Feb 13, 2024
1 parent 6799cee commit e82ca0b
Show file tree
Hide file tree
Showing 8 changed files with 284 additions and 16 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package org.tbk.lightning.lnurl.example;

import kotlin.Pair;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ActiveProfiles;
import org.tbk.lnurl.auth.SignedLnurlAuth;
import org.tbk.lnurl.test.SimpleLnurlWallet;

import java.security.SecureRandom;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment;

@SpringBootTest(
webEnvironment = WebEnvironment.RANDOM_PORT,
classes = LnurlAuthExampleApplication.class
)
@ActiveProfiles("test")
class AuthenticatedApiTest {
private static final SecureRandom random = new SecureRandom();

private static SimpleLnurlWallet testWallet;

@Autowired
private TestRestTemplate restTemplate;

@BeforeAll
static void setUpAll() {
byte[] seed = random.generateSeed(256);
testWallet = SimpleLnurlWallet.fromSeed(seed);
}

@Test
void itShouldFetchAuthenticatedUserJson() {
ResponseEntity<Object> request0 = restTemplate.exchange(RequestEntity
.get("/api/v1/authenticated/self")
.build(), Object.class);
assertThat("user cannot see any guarded resource", request0.getStatusCode(), is(HttpStatus.FORBIDDEN));

Pair<SignedLnurlAuth, String> signedAuthAndSessionId = new LnurlAuthFlowTest.LnurlAuthFlowTestHelper(restTemplate, testWallet).login();

ResponseEntity<String> authTestRequest2ResponseEntity = restTemplate.exchange(RequestEntity
.get("/api/v1/authenticated/self")
.header(HttpHeaders.COOKIE, "SESSION=%s".formatted(signedAuthAndSessionId.getSecond()))
.build(), String.class);
assertThat(authTestRequest2ResponseEntity.getStatusCode(), is(HttpStatus.OK));

assertThat(authTestRequest2ResponseEntity.getBody(), is("""
{
"username" : "%s",
"authorities" : [ {
"authority" : "ROLE_USER"
} ],
"accountNonExpired" : true,
"accountNonLocked" : true,
"credentialsNonExpired" : true,
"enabled" : true
}""".formatted(signedAuthAndSessionId.getFirst().getLinkingKey().toHex())));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;


@SpringBootTest
@ActiveProfiles("test")
class LnurlAuthExampleApplicationTest {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package org.tbk.lightning.lnurl.example;

import kotlin.Pair;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
Expand All @@ -15,15 +18,19 @@
import org.tbk.lnurl.auth.SignedLnurlAuth;
import org.tbk.lnurl.simple.SimpleLnurl;
import org.tbk.lnurl.simple.auth.SimpleLnurlAuth;
import org.tbk.lnurl.test.LnurlWallet;
import org.tbk.lnurl.test.SimpleLnurlWallet;

import java.security.SecureRandom;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import static org.tbk.lightning.lnurl.example.LnurlAuthExampleApplicationSecurityConfig.lnurlAuthLoginPagePath;
import static org.tbk.lightning.lnurl.example.LnurlAuthExampleApplicationSecurityConfig.lnurlAuthSessionLoginPath;

@SpringBootTest(
webEnvironment = WebEnvironment.RANDOM_PORT,
Expand All @@ -47,7 +54,7 @@ static void setUpAll() {
}

@Test
void lnurlAuthLoginSuccess() {
void lnurlAuthLoginSuccessBrowserUser() {
/*
* STEP 1: Create a session for web user (containing a newly created 'k1' value)
*
Expand All @@ -57,14 +64,15 @@ void lnurlAuthLoginSuccess() {
* This is done in a browser.
* A login page can, e.g. display a qr-code for wallets to scan.
*/
RequestEntity<Void> loginRequest = RequestEntity.get(LnurlAuthExampleApplicationSecurityConfig.lnurlAuthLoginPagePath())
RequestEntity<Void> loginRequest = RequestEntity.get(lnurlAuthLoginPagePath())
.build();

ResponseEntity<String> loginResponseEntity = restTemplate.exchange(loginRequest, String.class);

// e.g. Set-Cookie -> "SESSION=OTY3ZjJmNTYtZjkzZS00YTkyLTkwNDctZjA3NDU0MmI4MmUx; Path=/; HttpOnly; SameSite=Lax"
String cookieHeaderValue = loginResponseEntity.getHeaders().getFirst(HttpHeaders.SET_COOKIE);
assertThat("cookie header present", cookieHeaderValue, not(emptyOrNullString()));
assertThat("cookie header present", cookieHeaderValue, is(notNullValue()));
assertThat("cookie header value is not blank", cookieHeaderValue, is(not(blankOrNullString())));
Matcher cookieIdMatcher = sessionIdPattern.matcher(cookieHeaderValue);
assertThat("cookie id found", cookieIdMatcher.find(), is(true));
String sessionId = cookieIdMatcher.group(1);
Expand Down Expand Up @@ -117,7 +125,7 @@ void lnurlAuthLoginSuccess() {
* This request "prefers" application/json content.
* That's why it will respond with 200 OK (instead of 3xx with 'Location' header).
*/
RequestEntity<Void> sessionMigrateRequest = RequestEntity.get(LnurlAuthExampleApplicationSecurityConfig.lnurlAuthSessionLoginPath())
RequestEntity<Void> sessionMigrateRequest = RequestEntity.get(lnurlAuthSessionLoginPath())
.header(HttpHeaders.COOKIE, "SESSION=" + sessionId)
.build();
ResponseEntity<Object> sessionMigrateRequestResponseEntity = restTemplate.exchange(sessionMigrateRequest, Object.class);
Expand All @@ -126,7 +134,8 @@ void lnurlAuthLoginSuccess() {

// we have enabled "migrate session" in spring security and validate this behavior
String migratedCookieHeaderValue = sessionMigrateRequestResponseEntity.getHeaders().getFirst(HttpHeaders.SET_COOKIE);
assertThat("migrated cookie header present", migratedCookieHeaderValue, not(emptyOrNullString()));
assertThat("migrated cookie header present", migratedCookieHeaderValue, is(notNullValue()));
assertThat("cookie header value is not blank", migratedCookieHeaderValue, is(not(blankOrNullString())));
Matcher migratedCookieIdMatcher = sessionIdPattern.matcher(migratedCookieHeaderValue);
assertThat("migrated cookie id found", migratedCookieIdMatcher.find(), is(true));
String migratedSessionId = migratedCookieIdMatcher.group(1);
Expand All @@ -142,4 +151,172 @@ void lnurlAuthLoginSuccess() {
ResponseEntity<String> authTestRequest2ResponseEntity = restTemplate.exchange(authTestRequest2, String.class);
assertThat("Web user has been authenticated with wallet linking key", authTestRequest2ResponseEntity.getStatusCode(), is(HttpStatus.OK));
}
}

@Test
void lnurlAuthLoginSuccessApiUser() {
/*
* STEP 1: Create a session for web user (containing a newly created 'k1' value)
*
* Create a session with k1 value and return a bech32 encoded lnurl-auth string.
* Only the browser knowing the session ID can log in after a wallet signed the k1 value.
*
* This is done in a browser.
* A login page can, e.g. display a qr-code for wallets to scan.
*/
ResponseEntity<String> loginResponseEntity = restTemplate.exchange(RequestEntity
.get(lnurlAuthLoginPagePath())
.build(), String.class);

String sessionId = LnurlAuthFlowTestHelper.parseSessionIdFromCookie(loginResponseEntity.getHeaders())
.orElseThrow(() -> new IllegalStateException("Could not find sessionId"));

LnurlAuth lnurlAuth = LnurlAuthFlowTestHelper.parseFirstLnurlAuthStringInText(loginResponseEntity.getBody())
.orElseThrow(() -> new IllegalStateException("Could not find lnurl-auth string"));

SignedLnurlAuth signedLnurlAuth = testWallet.authorize(lnurlAuth);

// assert that the user still cannot see any guarded resource
ResponseEntity<Object> authTestRequest0ResponseEntity = restTemplate.exchange(RequestEntity
.get("/api/v1/authenticated/self")
.header(HttpHeaders.COOKIE, "SESSION=" + sessionId)
.build(), Object.class);
assertThat("user cannot see any guarded resource", authTestRequest0ResponseEntity.getStatusCode(), is(HttpStatus.FORBIDDEN));

/*
* STEP 2: Login with wallet
*
* Call url in lnurl-auth string with needed value (k1, sig, key)
* This step is done by the wallet.
* Most likely based on a scanned qr code.
*/
ResponseEntity<Object> walletLoginResponseEntity = restTemplate.getForEntity(UriComponentsBuilder
.fromUri(signedLnurlAuth.toLnurl().toUri())
.scheme(null).host(null).port(null).build()
.toUriString(), Object.class);
assertThat(walletLoginResponseEntity.getStatusCode(), is(HttpStatus.OK));

// assert that the user still cannot see any guarded resource
ResponseEntity<Object> authTestRequest1ResponseEntity = restTemplate.exchange(RequestEntity
.get("/api/v1/authenticated/self")
.header(HttpHeaders.COOKIE, "SESSION=" + sessionId)
.build(), Object.class);
assertThat("user still cannot see any guarded resource", authTestRequest1ResponseEntity.getStatusCode(), is(HttpStatus.FORBIDDEN));

/*
* STEP 3: Migrate the session for web user
*
* This is done in a browser.
* This request will migrate the session and link the web user with the wallet.
*
* This can be initiated as needed by your custom implementation (and is not forced upon you).
* e.g.
* - telling the user to click the link after successful auth (pro: simple; con: terrible user experience)
* - polling on the login page (waiting for a redirect) (pro: simple; con: polling, really? [...] )
* - called after a websocket response (pro: best UX; con: more complex)
* - etc.
*
* This request "prefers" application/json content.
* That's why it will respond with 200 OK (instead of 3xx with 'Location' header).
*/
ResponseEntity<Object> migrateSessionResponse = restTemplate.exchange(RequestEntity
.get(lnurlAuthSessionLoginPath())
.header(HttpHeaders.COOKIE, "SESSION=" + sessionId)
.build(), Object.class);

assertThat(migrateSessionResponse.getStatusCode(), is(HttpStatus.OK));

// we have enabled "migrate session" in spring security and validate this behavior
String migratedSessionId = LnurlAuthFlowTestHelper.parseSessionIdFromCookie(migrateSessionResponse.getHeaders())
.orElseThrow(() -> new IllegalStateException("Could not find migrated sessionId"));

/*
* STEP 4: User is now logged in and can access guarded resources.
*/
ResponseEntity<String> authTestRequest2ResponseEntity = restTemplate.exchange(RequestEntity
.get("/api/v1/authenticated/self")
.header(HttpHeaders.COOKIE, "SESSION=" + migratedSessionId)
.build(), String.class);
assertThat(authTestRequest2ResponseEntity.getStatusCode(), is(HttpStatus.OK));
}

@RequiredArgsConstructor
public static final class LnurlAuthFlowTestHelper {

// e.g. Set-Cookie -> "SESSION=OTY3ZjJmNTYtZjkzZS00YTkyLTkwNDctZjA3NDU0MmI4MmUx; Path=/; HttpOnly; SameSite=Lax"
public static Optional<String> parseSessionIdFromCookie(HttpHeaders headers) {
return Optional.ofNullable(headers)
.map(it -> it.getFirst(HttpHeaders.SET_COOKIE))
.map(sessionIdPattern::matcher)
.filter(Matcher::find)
.map(it -> it.group(1));
}

public static Optional<LnurlAuth> parseFirstLnurlAuthStringInText(String text) {
return Optional.ofNullable(text)
.map(it -> Pattern.compile(".*\"lightning:(lnurl1.*)\".*").matcher(it))
.filter(Matcher::find)
.map(it -> it.group(1))
.map(it -> SimpleLnurlAuth.parse(SimpleLnurl.fromBech32(it)));
}

@NonNull
private final TestRestTemplate restTemplate;

@NonNull
private final LnurlWallet wallet;

public Pair<SignedLnurlAuth, String> login() {
Pair<LnurlAuth, String> lnurlAuthAndSessionId = fetchLnurlAuthAndSessionId();

SignedLnurlAuth signedLnurlAuth = triggerWalletLogin(lnurlAuthAndSessionId.getFirst());

String migratedSessionId = triggerSessionMigration(lnurlAuthAndSessionId.getSecond());

return new Pair<>(signedLnurlAuth, migratedSessionId);
}

private Pair<LnurlAuth, String> fetchLnurlAuthAndSessionId() {
ResponseEntity<String> loginResponseEntity = restTemplate.exchange(RequestEntity
.get(lnurlAuthLoginPagePath())
.build(), String.class);

String sessionId = parseSessionIdFromCookie(loginResponseEntity.getHeaders())
.orElseThrow(() -> new IllegalStateException("Could not find sessionId"));

LnurlAuth lnurlAuth = parseFirstLnurlAuthStringInText(loginResponseEntity.getBody())
.orElseThrow(() -> new IllegalStateException("Could not find lnurl-auth string"));

return new Pair<>(lnurlAuth, sessionId);
}


private SignedLnurlAuth triggerWalletLogin(LnurlAuth auth) {
SignedLnurlAuth signedLnurlAuth = wallet.authorize(auth);

ResponseEntity<Object> walletLoginResponse = restTemplate.getForEntity(UriComponentsBuilder
.fromUri(signedLnurlAuth.toLnurl().toUri())
.scheme(null).host(null).port(null).build()
.toUriString(), Object.class);

if (!walletLoginResponse.getStatusCode().is2xxSuccessful()) {
throw new IllegalStateException("Could not login with wallet");
}

return signedLnurlAuth;
}

private String triggerSessionMigration(String sessionId) {
ResponseEntity<Object> migrateSessionResponse = restTemplate.exchange(RequestEntity
.get(lnurlAuthSessionLoginPath())
.header(HttpHeaders.COOKIE, "SESSION=%s".formatted(sessionId))
.build(), Object.class);

if (!migrateSessionResponse.getStatusCode().is2xxSuccessful()) {
throw new IllegalStateException("Could not migrate session");
}

return parseSessionIdFromCookie(migrateSessionResponse.getHeaders())
.orElseThrow(() -> new IllegalStateException("Could not find migrated sessionId"));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package org.tbk.lightning.lnurl.example.api;

import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/authenticated")
@RequiredArgsConstructor
public class AuthenticatedApi {

@GetMapping(path = "/self", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<UserDetails> authenticationPrincipalUserDetails(@AuthenticationPrincipal(errorOnInvalidType = true) UserDetails currentUser) {
return ResponseEntity.status(HttpStatus.OK)
.body(currentUser);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,4 @@ private static BufferedImage createImageWithText(String text) {

return bufferedImage;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
Expand All @@ -10,6 +11,7 @@
import org.springframework.http.MediaType;
import org.springframework.security.authentication.event.AbstractAuthenticationFailureEvent;
import org.springframework.security.authentication.event.AuthenticationSuccessEvent;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.event.ApplicationEvents;
import org.springframework.test.context.event.RecordApplicationEvents;
Expand Down Expand Up @@ -114,9 +116,8 @@ void sessionLoginSuccessBrowser() throws Exception {

LnurlAuthSessionToken sessionToken = (LnurlAuthSessionToken) authenticationSuccessEvent.getAuthentication();
assertThat(sessionToken.getK1(), is(k1));
// just in our case, the principal is also the linking key
// Note: Other implementation might use something else!
assertThat(sessionToken.getPrincipal(), is(signedLnurlAuth.getLinkingKey().toHex()));
assertThat(sessionToken.getPrincipal(), instanceOf(UserDetails.class));
assertThat(((UserDetails) sessionToken.getPrincipal()).getUsername(), is(signedLnurlAuth.getLinkingKey().toHex()));
}

@Test
Expand Down Expand Up @@ -149,9 +150,8 @@ void sessionLoginSuccessXhr() throws Exception {

LnurlAuthSessionToken sessionToken = (LnurlAuthSessionToken) authenticationSuccessEvent.getAuthentication();
assertThat(sessionToken.getK1(), is(k1));
// just in our case, the principal is also the linking key
// Note: Other implementation might use something else!
assertThat(sessionToken.getPrincipal(), is(signedLnurlAuth.getLinkingKey().toHex()));
assertThat(sessionToken.getPrincipal(), instanceOf(UserDetails.class));
assertThat(((UserDetails) sessionToken.getPrincipal()).getUsername(), is(signedLnurlAuth.getLinkingKey().toHex()));
}

@Test
Expand Down
Loading

0 comments on commit e82ca0b

Please sign in to comment.