diff --git a/server/auth.go b/server/auth.go index e94cce5..385bb9a 100644 --- a/server/auth.go +++ b/server/auth.go @@ -24,6 +24,7 @@ type UserAuth struct { type SessionData struct { ID string DisplayName string + UserInfo map[string]any } func (u UserAuth) IsGuest() bool { diff --git a/server/login.go b/server/login.go index e84dea5..e3e7ca8 100644 --- a/server/login.go +++ b/server/login.go @@ -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 +} diff --git a/server/server.go b/server/server.go index d33c1d4..b3f9ed3 100644 --- a/server/server.go +++ b/server/server.go @@ -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 { diff --git a/test-client/index.html b/test-client/index.html index 70dab64..a54115b 100644 --- a/test-client/index.html +++ b/test-client/index.html @@ -1,142 +1,92 @@ - Test Client - + + + #tokenValues { + width: 400px; + height: 400px; + } +
-

Test Client

+

Test Client

-
- - -
-
-
- -
-
- -
+
-
-

Permissions:

-
    +
    +
    +
    + +
    +
    + +
    +
    +
    +

    Permissions:

    +
      +
      -
      diff --git a/test-client/popup.js b/test-client/popup.js new file mode 100644 index 0000000..e1d8e0c --- /dev/null +++ b/test-client/popup.js @@ -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);