mirror of
https://github.com/1f349/lavender.git
synced 2024-12-22 07:34:06 +00:00
Exchange token and fetch userinfo
This commit is contained in:
parent
1f2be68470
commit
9228f6649e
@ -24,6 +24,7 @@ type UserAuth struct {
|
||||
type SessionData struct {
|
||||
ID string
|
||||
DisplayName string
|
||||
UserInfo map[string]any
|
||||
}
|
||||
|
||||
func (u UserAuth) IsGuest() bool {
|
||||
|
@ -1,6 +1,12 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"github.com/1f349/lavender/pages"
|
||||
"github.com/google/uuid"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
@ -11,7 +17,12 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func (h *HttpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
|
||||
func (h *HttpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
|
||||
if !auth.IsGuest() {
|
||||
h.SafeRedirect(rw, req)
|
||||
return
|
||||
}
|
||||
|
||||
cookie, err := req.Cookie("lavender-login-name")
|
||||
if err == nil && cookie.Valid() == nil {
|
||||
pages.RenderPageTemplate(rw, "login-memory", map[string]any{
|
||||
@ -27,7 +38,12 @@ func (h *HttpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httpr
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HttpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
|
||||
func (h *HttpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
|
||||
if !auth.IsGuest() {
|
||||
h.SafeRedirect(rw, req)
|
||||
return
|
||||
}
|
||||
|
||||
if req.PostFormValue("not-you") == "1" {
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: "lavender-login-name",
|
||||
@ -77,3 +93,79 @@ func (h *HttpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ http
|
||||
nextUrl := oa2conf.AuthCodeURL(state, oauth2.SetAuthURLParam("login_name", loginUn))
|
||||
http.Redirect(rw, req, nextUrl, http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
|
||||
flowState, ok := h.flowState.Get(req.FormValue("state"))
|
||||
if !ok {
|
||||
http.Error(rw, "Invalid flow state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
token, err := flowState.sso.OAuth2Config.Exchange(context.Background(), req.FormValue("code"), oauth2.SetAuthURLParam("redirect_uri", h.conf.BaseUrl+"/callback"))
|
||||
if err != nil {
|
||||
http.Error(rw, "Failed to exchange code for token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := flowState.sso.OAuth2Config.Client(context.Background(), token).Get(flowState.sso.UserInfoEndpoint)
|
||||
if err != nil || res.StatusCode != 200 {
|
||||
rw.WriteHeader(http.StatusInternalServerError)
|
||||
if err != nil {
|
||||
_, _ = rw.Write([]byte(err.Error()))
|
||||
} else {
|
||||
_, _ = rw.Write([]byte(res.Status))
|
||||
}
|
||||
return
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var userInfoJson map[string]any
|
||||
if err := json.NewDecoder(res.Body).Decode(&userInfoJson); err != nil {
|
||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
subject, ok := userInfoJson["sub"].(string)
|
||||
if !ok {
|
||||
http.Error(rw, "Invalid subject", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
subject += "@" + flowState.sso.Config.Namespace
|
||||
|
||||
displayName, ok := userInfoJson["name"].(string)
|
||||
if !ok {
|
||||
displayName = "Unknown Name"
|
||||
}
|
||||
|
||||
// only continues if the above tx succeeds
|
||||
auth.Data = SessionData{
|
||||
ID: subject,
|
||||
DisplayName: displayName,
|
||||
UserInfo: userInfoJson,
|
||||
}
|
||||
if auth.SaveSessionData() != nil {
|
||||
http.Error(rw, "Failed to save session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if h.setLoginDataCookie(rw, auth.Data.ID) {
|
||||
http.Error(rw, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.SafeRedirect(rw, req)
|
||||
}
|
||||
|
||||
func (h *HttpServer) setLoginDataCookie(rw http.ResponseWriter, userId string) bool {
|
||||
encryptedData, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, h.signingKey.PublicKey(), []byte(userId), []byte("login-data"))
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
encryptedString := base64.RawStdEncoding.EncodeToString(encryptedData)
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: "login-data",
|
||||
Value: encryptedString,
|
||||
Path: "/",
|
||||
Expires: time.Now().AddDate(0, 3, 0),
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
@ -18,6 +18,7 @@ import (
|
||||
"github.com/go-oauth2/oauth2/v4/manage"
|
||||
"github.com/go-oauth2/oauth2/v4/server"
|
||||
"github.com/go-oauth2/oauth2/v4/store"
|
||||
"github.com/go-session/session"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"log"
|
||||
"net/http"
|
||||
@ -44,6 +45,8 @@ type flowStateData struct {
|
||||
}
|
||||
|
||||
func NewHttpServer(conf Conf, db *database.DB, signingKey mjwt.Signer) *http.Server {
|
||||
session.InitManager(session.SetCookieName("lavender_session"))
|
||||
|
||||
r := httprouter.New()
|
||||
|
||||
// remove last slash from baseUrl
|
||||
@ -117,8 +120,9 @@ func NewHttpServer(conf Conf, db *database.DB, signingKey mjwt.Signer) *http.Ser
|
||||
r.GET("/", hs.OptionalAuthentication(hs.Home))
|
||||
|
||||
// login
|
||||
r.GET("/login", hs.loginGet)
|
||||
r.POST("/login", hs.loginPost)
|
||||
r.GET("/login", hs.OptionalAuthentication(hs.loginGet))
|
||||
r.POST("/login", hs.OptionalAuthentication(hs.loginPost))
|
||||
r.GET("/callback", hs.OptionalAuthentication(hs.loginCallback))
|
||||
r.POST("/logout", hs.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) {
|
||||
lNonce, ok := auth.Session.Get("action-nonce")
|
||||
if !ok {
|
||||
|
@ -1,142 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Test Client</title>
|
||||
<script>
|
||||
let currentLoginPopup = null;
|
||||
let currentTokens = null;
|
||||
const ssoService = "http://localhost:9090";
|
||||
<title>Test Client</title>
|
||||
<script src="popup.js"></script>
|
||||
<script>
|
||||
let currentTokens = null;
|
||||
const ssoService = "http://localhost:9090";
|
||||
|
||||
function updateTokenInfo(data) {
|
||||
currentTokens = data.tokens;
|
||||
data.tokens = {
|
||||
access: "*****",
|
||||
refresh: "*****",
|
||||
}
|
||||
document.getElementById("someTextArea").textContent = JSON.stringify(data, null, 2);
|
||||
let perms = document.getElementById("somePerms");
|
||||
while (perms.childNodes.length > 0) {
|
||||
perms.childNodes.item(0).remove();
|
||||
}
|
||||
document.getElementById("tokenValues").textContent = JSON.stringify(currentTokens, null, 2);
|
||||
POP2.init(ssoService + "/authorize", "a", "openid profile", 500, 600);
|
||||
|
||||
let jwt = parseJwt(currentTokens.access);
|
||||
if (jwt.per != null) {
|
||||
jwt.per.forEach(function (x) {
|
||||
let a = document.createElement("li");
|
||||
a.textContent = x;
|
||||
perms.appendChild(a);
|
||||
});
|
||||
}
|
||||
}
|
||||
function updateTokenInfo(data) {
|
||||
currentTokens = data.tokens;
|
||||
data.tokens = {
|
||||
access: "*****",
|
||||
refresh: "*****",
|
||||
}
|
||||
document.getElementById("someTextArea").textContent = JSON.stringify(data, null, 2);
|
||||
let perms = document.getElementById("somePerms");
|
||||
while (perms.childNodes.length > 0) {
|
||||
perms.childNodes.item(0).remove();
|
||||
}
|
||||
document.getElementById("tokenValues").textContent = JSON.stringify(currentTokens, null, 2);
|
||||
|
||||
window.addEventListener("message", function (event) {
|
||||
if (event.origin !== ssoService) return;
|
||||
if (isObject(event.data)) {
|
||||
updateTokenInfo(event.data);
|
||||
let jwt = parseJwt(currentTokens.access);
|
||||
if (jwt.per != null) {
|
||||
jwt.per.forEach(function (x) {
|
||||
let a = document.createElement("li");
|
||||
a.textContent = x;
|
||||
perms.appendChild(a);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLoginPopup) currentLoginPopup.close();
|
||||
return;
|
||||
}
|
||||
alert("Failed to log user in: the login data was probably corrupted");
|
||||
});
|
||||
function parseJwt(token) {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(window.atob(base64).split('').map(function (c) {
|
||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(''));
|
||||
return JSON.parse(jsonPayload);
|
||||
}
|
||||
|
||||
function parseJwt(token) {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(window.atob(base64).split('').map(function (c) {
|
||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(''));
|
||||
return JSON.parse(jsonPayload);
|
||||
}
|
||||
function doThisThing() {
|
||||
POP2.getToken(function (token) {
|
||||
console.log(token);
|
||||
});
|
||||
}
|
||||
|
||||
function isObject(obj) {
|
||||
return obj != null && obj.constructor.name === "Object"
|
||||
}
|
||||
|
||||
function popupCenterScreen(url, title, w, h, focus) {
|
||||
const top = (screen.availHeight - h) / 4, left = (screen.availWidth - w) / 2;
|
||||
const popup = openWindow(url, title, `scrollbars=yes,width=${w},height=${h},top=${top},left=${left}`);
|
||||
if (focus === true && window.focus) popup.focus();
|
||||
return popup;
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
function openWindow(url, winnm, options) {
|
||||
var wTop = firstAvailableValue([window.screen.availTop, window.screenY, window.screenTop, 0]);
|
||||
var wLeft = firstAvailableValue([window.screen.availLeft, window.screenX, window.screenLeft, 0]);
|
||||
var top = 0, left = 0;
|
||||
var result;
|
||||
if ((result = /top=(\d+)/g.exec(options))) top = parseInt(result[1]);
|
||||
if ((result = /left=(\d+)/g.exec(options))) left = parseInt(result[1]);
|
||||
if (options) {
|
||||
options = options.replace("top=" + top, "top=" + (parseInt(top) + wTop));
|
||||
options = options.replace("left=" + left, "left=" + (parseInt(left) + wLeft));
|
||||
w = window.open(url, winnm, options);
|
||||
} else w = window.open(url, winnm);
|
||||
return w;
|
||||
}
|
||||
#someTextArea {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
function firstAvailableValue(arr) {
|
||||
for (var i = 0; i < arr.length; i++)
|
||||
if (typeof arr[i] != 'undefined')
|
||||
return arr[i];
|
||||
}
|
||||
|
||||
function doThisThing() {
|
||||
if (currentLoginPopup) currentLoginPopup.close();
|
||||
currentLoginPopup = popupCenterScreen(ssoService + '/popup?origin=' + encodeURIComponent(location.origin), 'Login with Lavender', 500, 500, false);
|
||||
}
|
||||
|
||||
async function refreshAllTokens() {
|
||||
let req = await fetch(ssoService + '/refresh', {
|
||||
method: 'POST',
|
||||
mode: 'cors',
|
||||
cache: 'no-cache',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({"token": currentTokens.refresh}),
|
||||
});
|
||||
let reqJson = await req.json();
|
||||
updateTokenInfo(reqJson);
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
#someTextArea {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
#tokenValues {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
}
|
||||
</style>
|
||||
#tokenValues {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Test Client</h1>
|
||||
<h1>Test Client</h1>
|
||||
</header>
|
||||
<main>
|
||||
<div>
|
||||
<button onclick="doThisThing();">Login</button>
|
||||
<button onclick="refreshAllTokens();">Refresh</button>
|
||||
</div>
|
||||
<div style="display:flex; gap: 2em;">
|
||||
<div>
|
||||
<div>
|
||||
<label for="someTextArea"></label><textarea id="someTextArea"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tokenValues"></label><textarea id="tokenValues"></textarea>
|
||||
</div>
|
||||
<button onclick="doThisThing();">Login</button>
|
||||
</div>
|
||||
<div>
|
||||
<p>Permissions:</p>
|
||||
<ul id="somePerms"></ul>
|
||||
<div style="display:flex; gap: 2em;">
|
||||
<div>
|
||||
<div>
|
||||
<label for="someTextArea"></label><textarea id="someTextArea"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tokenValues"></label><textarea id="tokenValues"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>Permissions:</p>
|
||||
<ul id="somePerms"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
134
test-client/popup.js
Normal file
134
test-client/popup.js
Normal file
@ -0,0 +1,134 @@
|
||||
/* Simple OAuth 2.0 Client flow library
|
||||
|
||||
Author: MrMelon54, timdream
|
||||
|
||||
Usage:
|
||||
POP2.init(client_id, scope)
|
||||
Initialize the library.
|
||||
redirect_uri is the current page (window.location.href).
|
||||
This function should be put before Analytics so that the second click won't result a page view register.
|
||||
POP2.getToken(callback)
|
||||
Send access token to the callback function as the first argument.
|
||||
If not logged in this triggers login popup and execute login after logged in.
|
||||
Be sure to call this function in user-triggered event (such as click) to prevent popup blocker.
|
||||
If not sure do use isLoggedIn() below to check first.
|
||||
POP2.isLoggedIn()
|
||||
boolean
|
||||
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
(function (w) {
|
||||
const windowName = 'pop2_oauth2_login_popup';
|
||||
|
||||
if (window.name === windowName) {
|
||||
if (
|
||||
window.opener &&
|
||||
window.opener.POP2
|
||||
) {
|
||||
if (window.location.hash.indexOf('access_token') !== -1) {
|
||||
window.opener.POP2.receiveToken(
|
||||
window.location.hash.replace(/^.*access_token=([^&]+).*$/, '$1'),
|
||||
parseInt(window.location.hash.replace(/^.*expires_in=([^&]+).*$/, '$1'))
|
||||
);
|
||||
}
|
||||
if (window.location.search.indexOf('error=')) {
|
||||
window.opener.POP2.receiveToken('ERROR');
|
||||
}
|
||||
}
|
||||
window.close();
|
||||
}
|
||||
|
||||
function popupCenterScreen(url, title, w, h) {
|
||||
const top = (screen.availHeight - h) / 4, left = (screen.availWidth - w) / 2;
|
||||
return openWindow(url, title, `scrollbars=yes,width=${w},height=${h},top=${top},left=${left}`);
|
||||
}
|
||||
|
||||
function openWindow(url, winnm, options) {
|
||||
const wTop = firstAvailableValue([window.screen.availTop, window.screenY, window.screenTop, 0]);
|
||||
const wLeft = firstAvailableValue([window.screen.availLeft, window.screenX, window.screenLeft, 0]);
|
||||
let top = "0",
|
||||
left = "0",
|
||||
result;
|
||||
if ((result = /top=(\d+)/g.exec(options))) top = parseInt(result[1]);
|
||||
if ((result = /left=(\d+)/g.exec(options))) left = parseInt(result[1]);
|
||||
if (options) {
|
||||
options = options.replace("top=" + top, "top=" + (parseInt(top) + wTop));
|
||||
options = options.replace("left=" + left, "left=" + (parseInt(left) + wLeft));
|
||||
w = window.open(url, winnm, options);
|
||||
} else w = window.open(url, winnm);
|
||||
return w;
|
||||
}
|
||||
|
||||
function firstAvailableValue(arr) {
|
||||
for (let i = 0; i < arr.length; i++)
|
||||
if (typeof arr[i] != 'undefined')
|
||||
return arr[i];
|
||||
}
|
||||
|
||||
let client_endpoint,
|
||||
client_id,
|
||||
scope = '',
|
||||
redirect_uri = window.location.href.substr(0, window.location.href.length - window.location.hash.length).replace(/#$/, ''),
|
||||
access_token,
|
||||
callbackWaitForToken,
|
||||
w_width = 400,
|
||||
w_height = 360;
|
||||
|
||||
w.POP2 = {
|
||||
// init
|
||||
init: function (f_client_endpoint, f_client_id, f_scope, width, height) {
|
||||
if (!f_client_endpoint) return false;
|
||||
if (!f_client_id) return false;
|
||||
client_endpoint = f_client_endpoint;
|
||||
client_id = f_client_id;
|
||||
if (f_scope) scope = f_scope;
|
||||
if (width) w_width = width;
|
||||
if (height) w_height = height;
|
||||
},
|
||||
// receive token from popup
|
||||
receiveToken: function (token, expires_in) {
|
||||
if (token !== 'ERROR') {
|
||||
access_token = token;
|
||||
if (callbackWaitForToken) callbackWaitForToken(access_token);
|
||||
setTimeout(
|
||||
function () {
|
||||
access_token = undefined;
|
||||
},
|
||||
expires_in * 1000
|
||||
);
|
||||
} else if (token === false) {
|
||||
callbackWaitForToken = undefined;
|
||||
}
|
||||
},
|
||||
// boolean, indicate logged in or not
|
||||
isLoggedIn: function () {
|
||||
return !!access_token;
|
||||
},
|
||||
// pass the access token to callback
|
||||
// if not logged in this triggers login popup;
|
||||
// use isLoggedIn to check login first to prevent popup blocker
|
||||
getToken: function (callback) {
|
||||
if (!client_id || !redirect_uri || !scope) {
|
||||
alert('You need init() first. Check the program flow.');
|
||||
return false;
|
||||
}
|
||||
if (!access_token) {
|
||||
callbackWaitForToken = callback;
|
||||
popupCenterScreen(
|
||||
client_endpoint
|
||||
+ '?response_type=token'
|
||||
+ '&redirect_uri=' + encodeURIComponent(redirect_uri)
|
||||
+ '&scope=' + encodeURIComponent(scope)
|
||||
+ '&client_id=' + encodeURIComponent(client_id),
|
||||
windowName,
|
||||
w_width,
|
||||
w_height
|
||||
);
|
||||
} else {
|
||||
return callback(access_token);
|
||||
}
|
||||
}
|
||||
};
|
||||
})(this);
|
Loading…
Reference in New Issue
Block a user