Browse Source

Added wip sso server

Toby Chui 6 months ago
parent
commit
955515c54e
7 changed files with 663 additions and 0 deletions
  1. 1 0
      go.mod
  2. 2 0
      go.sum
  3. 330 0
      mod/auth/sso/handlers.go
  4. 101 0
      mod/auth/sso/server.go
  5. 100 0
      mod/auth/sso/sso.go
  6. 43 0
      mod/auth/sso/static/index.html
  7. 86 0
      mod/auth/sso/users.go

+ 1 - 0
go.mod

@@ -161,6 +161,7 @@ require (
 	github.com/ultradns/ultradns-go-sdk v1.6.1-20231103022937-8589b6a // indirect
 	github.com/vinyldns/go-vinyldns v0.9.16 // indirect
 	github.com/vultr/govultr/v2 v2.17.2 // indirect
+	github.com/xlzd/gotp v0.1.0 // indirect
 	github.com/yandex-cloud/go-genproto v0.0.0-20220805142335-27b56ddae16f // indirect
 	github.com/yandex-cloud/go-sdk v0.0.0-20220805164847-cf028e604997 // indirect
 	go.opencensus.io v0.24.0 // indirect

+ 2 - 0
go.sum

@@ -671,6 +671,8 @@ github.com/vinyldns/go-vinyldns v0.9.16/go.mod h1:5qIJOdmzAnatKjurI+Tl4uTus7GJKJ
 github.com/vultr/govultr/v2 v2.17.2 h1:gej/rwr91Puc/tgh+j33p/BLR16UrIPnSr+AIwYWZQs=
 github.com/vultr/govultr/v2 v2.17.2/go.mod h1:ZFOKGWmgjytfyjeyAdhQlSWwTjh2ig+X49cAp50dzXI=
 github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
+github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
+github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
 github.com/yandex-cloud/go-genproto v0.0.0-20220805142335-27b56ddae16f h1:cG+ehPRJSlqljSufLf1KXeXpUd1dLNjnzA18mZcB/O0=
 github.com/yandex-cloud/go-genproto v0.0.0-20220805142335-27b56ddae16f/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
 github.com/yandex-cloud/go-sdk v0.0.0-20220805164847-cf028e604997 h1:2wzke3JH7OtN20WsNDZx2VH/TCmsbqtDEbXzjF+i05E=

+ 330 - 0
mod/auth/sso/handlers.go

@@ -0,0 +1,330 @@
+package sso
+
+import (
+	"encoding/json"
+	"errors"
+	"net/http"
+
+	"github.com/gofrs/uuid"
+	"imuslab.com/zoraxy/mod/auth"
+	"imuslab.com/zoraxy/mod/utils"
+)
+
+/* Handlers for SSO user management */
+
+// HandleAddUser handle the request to add a new user to the SSO system
+func (s *SSOHandler) HandleAddUser(w http.ResponseWriter, r *http.Request) {
+	username, err := utils.PostPara(r, "username")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid username given")
+		return
+	}
+
+	password, err := utils.PostPara(r, "password")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid password given")
+		return
+	}
+
+	newUserId, err := uuid.NewV4()
+	if err != nil {
+		utils.SendErrorResponse(w, "failed to generate new user ID")
+		return
+	}
+
+	//Create a new user entry
+	thisUserEntry := UserEntry{
+		UserID:       newUserId.String(),
+		Username:     username,
+		PasswordHash: auth.Hash(password),
+		TOTPCode:     "",
+		Enable2FA:    false,
+	}
+
+	js, _ := json.Marshal(thisUserEntry)
+
+	//Create a new user in the database
+	err = s.Config.Database.Write("sso_users", newUserId.String(), string(js))
+	if err != nil {
+		utils.SendErrorResponse(w, "failed to create new user")
+		return
+	}
+	utils.SendOK(w)
+}
+
+// Edit user information, only accept change of username, password and enabled subdomain filed
+func (s *SSOHandler) HandleEditUser(w http.ResponseWriter, r *http.Request) {
+	userID, err := utils.PostPara(r, "user_id")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid user ID given")
+		return
+	}
+
+	if !(s.SSO_UserExists(userID)) {
+		utils.SendErrorResponse(w, "user not found")
+		return
+	}
+
+	//Load the user entry from database
+	userEntry, err := s.SSO_GetUser(userID)
+	if err != nil {
+		utils.SendErrorResponse(w, "failed to load user entry")
+		return
+	}
+
+	//Update each of the fields if it is provided
+	username, err := utils.PostPara(r, "username")
+	if err == nil {
+		userEntry.Username = username
+	}
+
+	password, err := utils.PostPara(r, "password")
+	if err == nil {
+		userEntry.PasswordHash = auth.Hash(password)
+	}
+
+	//Update the user entry in the database
+	js, _ := json.Marshal(userEntry)
+	err = s.Config.Database.Write("sso_users", userID, string(js))
+	if err != nil {
+		utils.SendErrorResponse(w, "failed to update user entry")
+		return
+	}
+	utils.SendOK(w)
+}
+
+// HandleRemoveUser remove a user from the SSO system
+func (s *SSOHandler) HandleRemoveUser(w http.ResponseWriter, r *http.Request) {
+	userID, err := utils.PostPara(r, "user_id")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid user ID given")
+		return
+	}
+
+	if !(s.SSO_UserExists(userID)) {
+		utils.SendErrorResponse(w, "user not found")
+		return
+	}
+
+	//Remove the user from the database
+	err = s.Config.Database.Delete("sso_users", userID)
+	if err != nil {
+		utils.SendErrorResponse(w, "failed to remove user")
+		return
+	}
+	utils.SendOK(w)
+}
+
+// HandleListUser list all users in the SSO system
+func (s *SSOHandler) HandleListUser(w http.ResponseWriter, r *http.Request) {
+	entries, err := s.Config.Database.ListTable("sso_users")
+	if err != nil {
+		utils.SendErrorResponse(w, "failed to list users")
+		return
+	}
+	ssoUsers := map[string]*UserEntry{}
+	for _, keypairs := range entries {
+		userid := string(keypairs[0])
+		group := new(UserEntry)
+		json.Unmarshal(keypairs[1], &group)
+		ssoUsers[userid] = group
+	}
+
+	js, _ := json.Marshal(ssoUsers)
+	utils.SendJSONResponse(w, string(js))
+}
+
+func (s *SSOHandler) HandleAddSubdomain(w http.ResponseWriter, r *http.Request) {
+	userid, err := utils.PostPara(r, "user_id")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid user ID given")
+		return
+	}
+
+	if !(s.SSO_UserExists(userid)) {
+		utils.SendErrorResponse(w, "user not found")
+		return
+	}
+
+	UserEntry, err := s.SSO_GetUser(userid)
+	if err != nil {
+		utils.SendErrorResponse(w, "failed to load user entry")
+		return
+	}
+
+	subdomain, err := utils.PostPara(r, "subdomain")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid subdomain given")
+		return
+	}
+
+	allowAccess, err := utils.PostBool(r, "allow_access")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid allow access value given")
+		return
+	}
+
+	UserEntry.Subdomains[subdomain] = &SubdomainAccessRule{
+		Subdomain:   subdomain,
+		AllowAccess: allowAccess,
+	}
+
+	err = UserEntry.Update()
+	if err != nil {
+		utils.SendErrorResponse(w, "failed to update user entry")
+		return
+	}
+
+	utils.SendOK(w)
+}
+
+func (s *SSOHandler) HandleRemoveSubdomain(w http.ResponseWriter, r *http.Request) {
+	userid, err := utils.PostPara(r, "user_id")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid user ID given")
+		return
+	}
+
+	if !(s.SSO_UserExists(userid)) {
+		utils.SendErrorResponse(w, "user not found")
+		return
+	}
+
+	UserEntry, err := s.SSO_GetUser(userid)
+	if err != nil {
+		utils.SendErrorResponse(w, "failed to load user entry")
+		return
+	}
+
+	subdomain, err := utils.PostPara(r, "subdomain")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid subdomain given")
+		return
+	}
+
+	delete(UserEntry.Subdomains, subdomain)
+
+	err = UserEntry.Update()
+	if err != nil {
+		utils.SendErrorResponse(w, "failed to update user entry")
+		return
+	}
+
+	utils.SendOK(w)
+}
+
+func (s *SSOHandler) HandleEnable2FA(w http.ResponseWriter, r *http.Request) {
+	userid, err := utils.PostPara(r, "user_id")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid user ID given")
+		return
+	}
+
+	if !(s.SSO_UserExists(userid)) {
+		utils.SendErrorResponse(w, "user not found")
+		return
+	}
+
+	UserEntry, err := s.SSO_GetUser(userid)
+	if err != nil {
+		utils.SendErrorResponse(w, "failed to load user entry")
+		return
+	}
+
+	UserEntry.Enable2FA = true
+	provisionUri, err := UserEntry.ResetTotp(UserEntry.UserID, "Zoraxy-SSO")
+	if err != nil {
+		utils.SendErrorResponse(w, "failed to reset TOTP")
+		return
+	}
+	//As the ResetTotp function will update the user entry in the database, no need to call Update here
+
+	js, _ := json.Marshal(provisionUri)
+	utils.SendJSONResponse(w, string(js))
+}
+
+// Handle Disable 2FA for a user
+func (s *SSOHandler) HandleDisable2FA(w http.ResponseWriter, r *http.Request) {
+	userid, err := utils.PostPara(r, "user_id")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid user ID given")
+		return
+	}
+
+	if !(s.SSO_UserExists(userid)) {
+		utils.SendErrorResponse(w, "user not found")
+		return
+	}
+
+	UserEntry, err := s.SSO_GetUser(userid)
+	if err != nil {
+		utils.SendErrorResponse(w, "failed to load user entry")
+		return
+	}
+
+	UserEntry.Enable2FA = false
+	UserEntry.TOTPCode = ""
+
+	err = UserEntry.Update()
+	if err != nil {
+		utils.SendErrorResponse(w, "failed to update user entry")
+		return
+	}
+
+	utils.SendOK(w)
+}
+
+// HandleVerify2FA verify the 2FA code for a user
+func (s *SSOHandler) HandleVerify2FA(w http.ResponseWriter, r *http.Request) (bool, error) {
+	userid, err := utils.PostPara(r, "user_id")
+	if err != nil {
+		return false, errors.New("invalid user ID given")
+	}
+
+	if !(s.SSO_UserExists(userid)) {
+		utils.SendErrorResponse(w, "user not found")
+		return false, errors.New("user not found")
+	}
+
+	UserEntry, err := s.SSO_GetUser(userid)
+	if err != nil {
+		utils.SendErrorResponse(w, "failed to load user entry")
+		return false, errors.New("failed to load user entry")
+	}
+
+	totpCode, _ := utils.PostPara(r, "totp_code")
+
+	if !UserEntry.Enable2FA {
+		//If 2FA is not enabled, return true
+		return true, nil
+	}
+
+	if !UserEntry.VerifyTotp(totpCode) {
+		return false, nil
+	}
+
+	return true, nil
+
+}
+
+/* Handlers for SSO portal server */
+
+func (s *SSOHandler) HandleStartSSOPortal(w http.ResponseWriter, r *http.Request) {
+	err := s.StartSSOPortal()
+	if err != nil {
+		s.Log("Failed to start SSO portal server", err)
+		utils.SendErrorResponse(w, "failed to start SSO portal server")
+		return
+	}
+	utils.SendOK(w)
+}
+
+func (s *SSOHandler) HandleStopSSOPortal(w http.ResponseWriter, r *http.Request) {
+	err := s.ssoPortalServer.Close()
+	if err != nil {
+		s.Log("Failed to stop SSO portal server", err)
+		utils.SendErrorResponse(w, "failed to stop SSO portal server")
+		return
+	}
+	utils.SendOK(w)
+}

+ 101 - 0
mod/auth/sso/server.go

@@ -0,0 +1,101 @@
+package sso
+
+import (
+	"context"
+	"net/http"
+	"strconv"
+	"time"
+
+	"imuslab.com/zoraxy/mod/utils"
+)
+
+/*
+	server.go
+
+	This is the router for the SSO authentication interface
+
+*/
+
+func (h *SSOHandler) InitSSOPortal(portalServerPort int) {
+	//Create a new web server for the SSO portal
+	pmux := http.NewServeMux()
+	fs := http.FileServer(http.FS(staticFiles))
+	pmux.Handle("/", fs)
+
+	//Register API endpoint for the SSO portal
+	pmux.HandleFunc("/login", h.HandleLogin)
+	//Add more API endpoints here
+
+	h.ssoPortalMux = pmux
+}
+
+// StartSSOPortal start the SSO portal server
+func (h *SSOHandler) StartSSOPortal() error {
+	h.ssoPortalServer = &http.Server{
+		Addr:    ":" + strconv.Itoa(h.Config.PortalServerPort),
+		Handler: h.ssoPortalMux,
+	}
+	err := h.ssoPortalServer.ListenAndServe()
+	if err != nil {
+		h.Log("Failed to start SSO portal server", err)
+	}
+	return err
+}
+
+// StopSSOPortal stop the SSO portal server
+func (h *SSOHandler) StopSSOPortal() error {
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	err := h.ssoPortalServer.Shutdown(ctx)
+	if err != nil {
+		h.Log("Failed to stop SSO portal server", err)
+		return err
+	}
+	return nil
+}
+
+// HandleLogin handle the login request
+func (h *SSOHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
+	//Handle the login request
+	username, err := utils.PostPara(r, "username")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid username or password")
+		return
+	}
+
+	password, err := utils.PostPara(r, "password")
+	if err != nil {
+		utils.SendErrorResponse(w, "invalid username or password")
+		return
+	}
+
+	rememberMe, err := utils.PostBool(r, "remember_me")
+	if err != nil {
+		rememberMe = false
+	}
+
+	//Check if the user exists
+	userEntry, err := h.SSO_GetUser(username)
+	if err != nil {
+		utils.SendErrorResponse(w, "user not found")
+		return
+	}
+
+	//Check if the password is correct
+	if !userEntry.VerifyPassword(password) {
+		utils.SendErrorResponse(w, "incorrect password")
+		return
+	}
+
+	//Create a new session for the user
+	session, _ := h.cookieStore.Get(r, "Zoraxy-SSO")
+	session.Values["username"] = username
+	if rememberMe {
+		session.Options.MaxAge = 86400 * 15 //15 days
+	} else {
+		session.Options.MaxAge = 3600 //1 hour
+	}
+	session.Save(r, w) //Save the session
+
+	utils.SendOK(w)
+}

+ 100 - 0
mod/auth/sso/sso.go

@@ -0,0 +1,100 @@
+package sso
+
+import (
+	"embed"
+	"net/http"
+
+	"github.com/gorilla/sessions"
+	"imuslab.com/zoraxy/mod/database"
+	"imuslab.com/zoraxy/mod/info/logger"
+)
+
+//go:embed static/*
+var staticFiles embed.FS //Static files for the SSO portal
+
+type SSOConfig struct {
+	SystemUUID       string //System UUID, should be passed in from main scope
+	AuthURL          string //Authentication subdomain URL
+	PortalServerPort int    //SSO portal server port
+	Database         *database.Database
+	Logger           *logger.Logger
+}
+type SSOHandler struct {
+	cookieStore     *sessions.CookieStore
+	ssoPortalServer *http.Server
+	ssoPortalMux    *http.ServeMux
+	Config          *SSOConfig
+}
+
+// Create a new Zoraxy SSO handler
+func NewSSOHandler(config *SSOConfig) (*SSOHandler, error) {
+	//Create a cookie store for the SSO handler
+	cookieStore := sessions.NewCookieStore([]byte(config.SystemUUID))
+	cookieStore.Options = &sessions.Options{
+		Path:     "",
+		Domain:   "",
+		MaxAge:   0,
+		Secure:   false,
+		HttpOnly: false,
+		SameSite: 0,
+	}
+	//Create a table for the new sso user management system
+	err := config.Database.NewTable("sso_users")
+	if err != nil {
+		return nil, err
+	}
+
+	//Create the SSO Handler
+	thisHandler := SSOHandler{
+		cookieStore: cookieStore,
+		Config:      config,
+	}
+	thisHandler.InitSSOPortal(config.PortalServerPort)
+
+	return &thisHandler, nil
+}
+
+// ServeHTTP handle the SSO request by forwarding auth to the authelia server
+// return false if the request is not authorized and shall not be proceed
+// Note that only accounts that have SSO enabled will be handled by this handler
+func (h *SSOHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) bool {
+	//Check if the user have the cookie "Zoraxy-SSO" set
+	session, err := h.cookieStore.Get(r, "Zoraxy-SSO")
+	if err != nil {
+		//Redirect to auth subdomain
+		http.Redirect(w, r, h.Config.AuthURL, http.StatusFound)
+		return false
+	}
+
+	//Check if the user is logged in
+	if session.Values["username"] != true {
+		//Redirect to auth subdomain
+		http.Redirect(w, r, h.Config.AuthURL, http.StatusFound)
+		return false
+	}
+
+	//Check if the current request subdomain is allowed
+	userName := session.Values["username"].(string)
+	user, err := h.SSO_GetUser(userName)
+	if err != nil {
+		//User might have been removed from SSO. Redirect to auth subdomain
+		http.Redirect(w, r, h.Config.AuthURL, http.StatusFound)
+		return false
+	}
+
+	//Check if the user have access to the current subdomain
+	if !user.Subdomains[r.Host].AllowAccess {
+		//User is not allowed to access the current subdomain. Sent 403
+		http.Error(w, "Forbidden", http.StatusForbidden)
+		//TODO: Use better looking template if exists
+		return false
+	}
+
+	//User is logged in, continue to the next handler
+	return true
+}
+
+// Log a message with the SSO module tag
+func (h *SSOHandler) Log(message string, err error) {
+	h.Config.Logger.PrintAndLog("SSO", message, err)
+}

+ 43 - 0
mod/auth/sso/static/index.html

@@ -0,0 +1,43 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <title>Login Page</title>
+    <link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css">
+</head>
+<body>
+    <div class="ui container">
+        <div class="ui middle aligned center aligned grid">
+            <div class="column">
+                <h2 class="ui teal image header">
+                    <div class="content">
+                        Log in to your account
+                    </div>
+                </h2>
+                <form class="ui large form">
+                    <div class="ui stacked segment">
+                        <div class="field">
+                            <div class="ui left icon input">
+                                <i class="user icon"></i>
+                                <input type="text" name="username" placeholder="Username">
+                            </div>
+                        </div>
+                        <div class="field">
+                            <div class="ui left icon input">
+                                <i class="lock icon"></i>
+                                <input type="password" name="password" placeholder="Password">
+                            </div>
+                        </div>
+                        <div class="ui fluid large teal submit button">Login</div>
+                    </div>
+                    <div class="ui error message"></div>
+                </form>
+                <div class="ui message">
+                    New to us? <a href="#">Sign Up</a>
+                </div>
+            </div>
+        </div>
+    </div>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.js"></script>
+</body>
+</html>

+ 86 - 0
mod/auth/sso/users.go

@@ -0,0 +1,86 @@
+package sso
+
+import (
+	"encoding/json"
+	"time"
+
+	"github.com/xlzd/gotp"
+	"imuslab.com/zoraxy/mod/auth"
+)
+
+/*
+	users.go
+
+	This file contains the user structure and user management
+	functions for the SSO module.
+
+	If you are looking for handlers, please refer to handlers.go.
+*/
+
+type SubdomainAccessRule struct {
+	Subdomain   string
+	AllowAccess bool
+}
+
+type UserEntry struct {
+	UserID       string                          //User ID, in UUIDv4 format
+	Username     string                          //Username
+	PasswordHash string                          //Password hash
+	TOTPCode     string                          //2FA TOTP code
+	Enable2FA    bool                            //Enable 2FA for this user
+	Subdomains   map[string]*SubdomainAccessRule //Subdomain and access rule
+	parent       *SSOHandler                     //Parent SSO handler
+}
+
+func (s *SSOHandler) SSO_UserExists(userid string) bool {
+	//Check if the user exists in the database
+	var userEntry UserEntry
+	err := s.Config.Database.Read("sso_users", userid, &userEntry)
+	if err != nil {
+		return false
+	}
+	return true
+}
+
+func (s *SSOHandler) SSO_GetUser(userid string) (UserEntry, error) {
+	//Load the user entry from database
+	var userEntry UserEntry
+	err := s.Config.Database.Read("sso_users", userid, &userEntry)
+	if err != nil {
+		return UserEntry{}, err
+	}
+	userEntry.parent = s
+	return userEntry, nil
+}
+
+func (s *UserEntry) VerifyPassword(password string) bool {
+	return s.PasswordHash == auth.Hash(password)
+}
+
+// Write changes in the user entry back to the database
+func (u *UserEntry) Update() error {
+	js, _ := json.Marshal(u)
+	err := u.parent.Config.Database.Write("sso_users", u.UserID, string(js))
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// Reset and update the TOTP code for the current user
+// Return the provision uri of the new TOTP code for Google Authenticator
+func (u *UserEntry) ResetTotp(accountName string, issuerName string) (string, error) {
+	u.TOTPCode = gotp.RandomSecret(16)
+	totp := gotp.NewDefaultTOTP(u.TOTPCode)
+	err := u.Update()
+	if err != nil {
+		return "", err
+	}
+	return totp.ProvisioningUri(accountName, issuerName), nil
+}
+
+// Verify the TOTP code at current time
+func (u *UserEntry) VerifyTotp(enteredCode string) bool {
+	totp := gotp.NewDefaultTOTP(u.TOTPCode)
+	return totp.Verify(enteredCode, time.Now().Unix())
+}