mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-04-28 16:38:12 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b231c2958 | |||
| c68a022ed0 | |||
| 5d95123dcb |
@@ -0,0 +1,27 @@
|
|||||||
|
# AI Usage Policy
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> By Tinyauth, we refer to the entire Tinyauth ([tinyauthapp](https://github.com/tinyauthapp)) organization and all of the repositories under it.
|
||||||
|
|
||||||
|
## How we utilize AI in Tinyauth
|
||||||
|
|
||||||
|
In Tinyauth, we see AI as another tool designed to help developers accelerate their work, ***not*** as something that should be doing the development for them. The ways we utilize large language models in Tinyauth are the following:
|
||||||
|
|
||||||
|
- **Pull request reviews**: We utilize [CodeRabbit](https://www.coderabbit.ai/) for reviews in our pull requests which helps us find and fix issues faster, minimizing the time maintainers have to spend reviewing.
|
||||||
|
- **Documentation and Issues**: We use [Dosu](https://dosu.dev/) to help resolve duplicate issues faster and automatically update our documentation based on changes in the code base.
|
||||||
|
- **In-Line Suggestions**: GitHub's [Copilot](https://github.com/features/copilot) is partially used to fill in boilerplate code through in-line suggestions.
|
||||||
|
|
||||||
|
## How we expect the community to use AI
|
||||||
|
|
||||||
|
We expect the Tinyauth community to use AI as a tool for faster development and not as a way to implement entire features through prompts. For this reason, the following guidelines are in place for AI generated content:
|
||||||
|
|
||||||
|
- **All usage must be clearly labeled**: Any content generated by AI must be clearly labeled as such. In the case that a pull request is clearly generated by AI and the author fails to disclose its use, it will be rejected.
|
||||||
|
- **All generated content should be completely understood by the account holder**: The human who utilized the large language model to generate content must have a thorough understanding of it. This includes understanding the resulting output to the full extent and being able to explain it in detail in case it's needed.
|
||||||
|
- **Automated systems are not allowed**: All forms of automated systems that utilize large language models to generate content without human oversight are forbidden. This includes any system that generates content without a human being directly involved in the process like for example with OpenClaw.
|
||||||
|
- **No generated content other than text is allowed**: Images, videos, audio and any other form of content generated by AI other than text is not allowed in Tinyauth.
|
||||||
|
- **AI pull requests are not guaranteed to be accepted or prioritized**: Any pull request that contains AI generated content is not guaranteed to be accepted and/or prioritized. The maintainers are responsible for reviewing all pull requests and determining whether or not they meet the standards of the project. AI generated content will be reviewed with the same standards as any other content, and may be rejected if it does not meet those standards.
|
||||||
|
- **Large generated pull requests will be rejected**: Any pull request that contains a large amount of generated content will be rejected. This is because it is difficult for the maintainers to review and verify large amounts of generated content.
|
||||||
|
|
||||||
|
## Tinyauth is developed by humans, for humans
|
||||||
|
|
||||||
|
Please remember that Tinyauth is developed by humans. While AI can be a useful tool for **assisting** in the development process, it should not be used in place of the human brain. Moving forward, we are committed to ensuring that most, if not all the content in Tinyauth is created and reviewed by humans, and that AI is only used as a tool to assist in the development process.
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
Contributing to Tinyauth is straightforward. Follow the steps below to set up a development server.
|
Contributing to Tinyauth is straightforward. Follow the steps below to set up a development server.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> If you are using large language models to contribute to the project, please ensure that you have read and understood the [AI Policy](AI_POLICY.md).
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Bun
|
- Bun
|
||||||
|
|||||||
+1
-1
@@ -6,4 +6,4 @@ It is recommended to use the [latest](https://github.com/tinyauthapp/tinyauth/re
|
|||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
Due to the nature of this app, it needs to be secure. If you discover any security issues or vulnerabilities in the app please contact me as soon as possible at <steve@doesmycode.work>. Please do not use the issues section to report security issues as I won't be able to patch them in time and they may get exploited by malicious actors.
|
Due to the nature of this app, it needs to be secure. If you discover any security issues or vulnerabilities in the app please contact me as soon as possible at <security@tinyauth.app>. Please do not use the issues section to report security issues as I won't be able to patch them in time and they may get exploited by malicious actors.
|
||||||
|
|||||||
@@ -80,5 +80,9 @@
|
|||||||
"profileScopeDescription": "Allows the app to access your profile information.",
|
"profileScopeDescription": "Allows the app to access your profile information.",
|
||||||
"groupsScopeName": "Groups",
|
"groupsScopeName": "Groups",
|
||||||
"groupsScopeDescription": "Allows the app to access your group information.",
|
"groupsScopeDescription": "Allows the app to access your group information.",
|
||||||
"backToLoginButton": "Back to login"
|
"backToLoginButton": "Back to login",
|
||||||
|
"phoneScopeName": "Phone",
|
||||||
|
"phoneScopeDescription": "Allows the app to access your phone number.",
|
||||||
|
"addressScopeName": "Address",
|
||||||
|
"addressScopeDescription": "Allows the app to access your address."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { toast } from "sonner";
|
|||||||
import { useOIDCParams } from "@/lib/hooks/oidc";
|
import { useOIDCParams } from "@/lib/hooks/oidc";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TFunction } from "i18next";
|
import { TFunction } from "i18next";
|
||||||
import { Mail, Shield, User, Users } from "lucide-react";
|
import { Mail, MapPin, Phone, Shield, User, Users } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -61,6 +61,18 @@ const createScopeMap = (t: TFunction<"translation", undefined>): Scope[] => {
|
|||||||
description: t("groupsScopeDescription"),
|
description: t("groupsScopeDescription"),
|
||||||
icon: <Users {...scopeMapIconProps} />,
|
icon: <Users {...scopeMapIconProps} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "phone",
|
||||||
|
name: t("phoneScopeName"),
|
||||||
|
description: t("phoneScopeDescription"),
|
||||||
|
icon: <Phone {...scopeMapIconProps} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "address",
|
||||||
|
name: t("addressScopeName"),
|
||||||
|
description: t("addressScopeDescription"),
|
||||||
|
icon: <MapPin {...scopeMapIconProps} />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ require (
|
|||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
gotest.tools/v3 v3.5.2
|
gotest.tools/v3 v3.5.2
|
||||||
modernc.org/sqlite v1.49.1
|
modernc.org/sqlite v1.50.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|||||||
@@ -351,8 +351,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
|
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
|
||||||
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
ALTER TABLE "oidc_userinfo" DROP COLUMN "given_name";
|
||||||
|
ALTER TABLE "oidc_userinfo" DROP COLUMN "family_name";
|
||||||
|
ALTER TABLE "oidc_userinfo" DROP COLUMN "middle_name";
|
||||||
|
ALTER TABLE "oidc_userinfo" DROP COLUMN "nickname";
|
||||||
|
ALTER TABLE "oidc_userinfo" DROP COLUMN "profile";
|
||||||
|
ALTER TABLE "oidc_userinfo" DROP COLUMN "picture";
|
||||||
|
ALTER TABLE "oidc_userinfo" DROP COLUMN "website";
|
||||||
|
ALTER TABLE "oidc_userinfo" DROP COLUMN "gender";
|
||||||
|
ALTER TABLE "oidc_userinfo" DROP COLUMN "birthdate";
|
||||||
|
ALTER TABLE "oidc_userinfo" DROP COLUMN "zoneinfo";
|
||||||
|
ALTER TABLE "oidc_userinfo" DROP COLUMN "locale";
|
||||||
|
ALTER TABLE "oidc_userinfo" DROP COLUMN "phone_number";
|
||||||
|
ALTER TABLE "oidc_userinfo" DROP COLUMN "address";
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
ALTER TABLE "oidc_userinfo" ADD COLUMN "given_name" TEXT NOT NULL DEFAULT "";
|
||||||
|
ALTER TABLE "oidc_userinfo" ADD COLUMN "family_name" TEXT NOT NULL DEFAULT "";
|
||||||
|
ALTER TABLE "oidc_userinfo" ADD COLUMN "middle_name" TEXT NOT NULL DEFAULT "";
|
||||||
|
ALTER TABLE "oidc_userinfo" ADD COLUMN "nickname" TEXT NOT NULL DEFAULT "";
|
||||||
|
ALTER TABLE "oidc_userinfo" ADD COLUMN "profile" TEXT NOT NULL DEFAULT "";
|
||||||
|
ALTER TABLE "oidc_userinfo" ADD COLUMN "picture" TEXT NOT NULL DEFAULT "";
|
||||||
|
ALTER TABLE "oidc_userinfo" ADD COLUMN "website" TEXT NOT NULL DEFAULT "";
|
||||||
|
ALTER TABLE "oidc_userinfo" ADD COLUMN "gender" TEXT NOT NULL DEFAULT "";
|
||||||
|
ALTER TABLE "oidc_userinfo" ADD COLUMN "birthdate" TEXT NOT NULL DEFAULT "";
|
||||||
|
ALTER TABLE "oidc_userinfo" ADD COLUMN "zoneinfo" TEXT NOT NULL DEFAULT "";
|
||||||
|
ALTER TABLE "oidc_userinfo" ADD COLUMN "locale" TEXT NOT NULL DEFAULT "";
|
||||||
|
ALTER TABLE "oidc_userinfo" ADD COLUMN "phone_number" TEXT NOT NULL DEFAULT "";
|
||||||
|
ALTER TABLE "oidc_userinfo" ADD COLUMN "address" TEXT NOT NULL DEFAULT "{}";
|
||||||
@@ -63,7 +63,7 @@ func (app *BootstrapApp) Setup() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse users
|
// Parse users
|
||||||
users, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile)
|
users, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile, app.config.Auth.UserAttributes)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -113,15 +113,43 @@ type ServerConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
IP IPConfig `description:"IP whitelisting config options." yaml:"ip"`
|
IP IPConfig `description:"IP whitelisting config options." yaml:"ip"`
|
||||||
Users []string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"`
|
Users []string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"`
|
||||||
UsersFile string `description:"Path to the users file." yaml:"usersFile"`
|
UserAttributes map[string]UserAttributes `description:"Map of per-user OIDC attributes (username -> attributes)." yaml:"userAttributes"`
|
||||||
SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"`
|
UsersFile string `description:"Path to the users file." yaml:"usersFile"`
|
||||||
SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"`
|
SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"`
|
||||||
SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime"`
|
SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"`
|
||||||
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
|
SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime"`
|
||||||
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
|
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
|
||||||
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"`
|
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
|
||||||
|
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserAttributes struct {
|
||||||
|
Name string `description:"Full name of the user." yaml:"name"`
|
||||||
|
GivenName string `description:"Given (first) name of the user." yaml:"givenName"`
|
||||||
|
FamilyName string `description:"Family (last) name of the user." yaml:"familyName"`
|
||||||
|
MiddleName string `description:"Middle name of the user." yaml:"middleName"`
|
||||||
|
Nickname string `description:"Nickname of the user." yaml:"nickname"`
|
||||||
|
Profile string `description:"URL of the user's profile page." yaml:"profile"`
|
||||||
|
Picture string `description:"URL of the user's profile picture." yaml:"picture"`
|
||||||
|
Website string `description:"URL of the user's website." yaml:"website"`
|
||||||
|
Email string `description:"Email address of the user." yaml:"email"`
|
||||||
|
Gender string `description:"Gender of the user." yaml:"gender"`
|
||||||
|
Birthdate string `description:"Birthdate of the user (YYYY-MM-DD)." yaml:"birthdate"`
|
||||||
|
Zoneinfo string `description:"Time zone of the user (e.g. Europe/Athens)." yaml:"zoneinfo"`
|
||||||
|
Locale string `description:"Locale of the user (e.g. en-US)." yaml:"locale"`
|
||||||
|
PhoneNumber string `description:"Phone number of the user." yaml:"phoneNumber"`
|
||||||
|
Address AddressClaim `description:"Address of the user." yaml:"address"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddressClaim struct {
|
||||||
|
Formatted string `description:"Full mailing address, formatted for display." yaml:"formatted" json:"formatted,omitempty"`
|
||||||
|
StreetAddress string `description:"Street address." yaml:"streetAddress" json:"street_address,omitempty"`
|
||||||
|
Locality string `description:"City or locality." yaml:"locality" json:"locality,omitempty"`
|
||||||
|
Region string `description:"State, province, or region." yaml:"region" json:"region,omitempty"`
|
||||||
|
PostalCode string `description:"Zip or postal code." yaml:"postalCode" json:"postal_code,omitempty"`
|
||||||
|
Country string `description:"Country." yaml:"country" json:"country,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type IPConfig struct {
|
type IPConfig struct {
|
||||||
@@ -228,6 +256,7 @@ type User struct {
|
|||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
TotpSecret string
|
TotpSecret string
|
||||||
|
Attributes UserAttributes
|
||||||
}
|
}
|
||||||
|
|
||||||
type LdapUser struct {
|
type LdapUser struct {
|
||||||
@@ -254,6 +283,7 @@ type UserContext struct {
|
|||||||
OAuthName string
|
OAuthName string
|
||||||
OAuthSub string
|
OAuthSub string
|
||||||
LdapGroups string
|
LdapGroups string
|
||||||
|
Attributes UserAttributes
|
||||||
}
|
}
|
||||||
|
|
||||||
// API responses and queries
|
// API responses and queries
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||||
@@ -105,16 +106,32 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
|||||||
|
|
||||||
controller.auth.RecordLoginAttempt(req.Username, true)
|
controller.auth.RecordLoginAttempt(req.Username, true)
|
||||||
|
|
||||||
|
var localUser *config.User
|
||||||
if userSearch.Type == "local" {
|
if userSearch.Type == "local" {
|
||||||
user := controller.auth.GetLocalUser(userSearch.Username)
|
user := controller.auth.GetLocalUser(userSearch.Username)
|
||||||
|
localUser = &user
|
||||||
|
}
|
||||||
|
|
||||||
|
if userSearch.Type == "local" && localUser != nil {
|
||||||
|
user := *localUser
|
||||||
|
|
||||||
if user.TotpSecret != "" {
|
if user.TotpSecret != "" {
|
||||||
tlog.App.Debug().Str("username", req.Username).Msg("User has TOTP enabled, requiring TOTP verification")
|
tlog.App.Debug().Str("username", req.Username).Msg("User has TOTP enabled, requiring TOTP verification")
|
||||||
|
|
||||||
|
name := user.Attributes.Name
|
||||||
|
if name == "" {
|
||||||
|
name = utils.Capitalize(user.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
email := user.Attributes.Email
|
||||||
|
if email == "" {
|
||||||
|
email = utils.CompileUserEmail(user.Username, controller.config.CookieDomain)
|
||||||
|
}
|
||||||
|
|
||||||
err := controller.auth.CreateSessionCookie(c, &repository.Session{
|
err := controller.auth.CreateSessionCookie(c, &repository.Session{
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Name: utils.Capitalize(user.Username),
|
Name: name,
|
||||||
Email: utils.CompileUserEmail(user.Username, controller.config.CookieDomain),
|
Email: email,
|
||||||
Provider: "local",
|
Provider: "local",
|
||||||
TotpPending: true,
|
TotpPending: true,
|
||||||
})
|
})
|
||||||
@@ -144,6 +161,15 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
|||||||
Provider: "local",
|
Provider: "local",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if userSearch.Type == "local" && localUser != nil {
|
||||||
|
if localUser.Attributes.Name != "" {
|
||||||
|
sessionCookie.Name = localUser.Attributes.Name
|
||||||
|
}
|
||||||
|
if localUser.Attributes.Email != "" {
|
||||||
|
sessionCookie.Email = localUser.Attributes.Email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if userSearch.Type == "ldap" {
|
if userSearch.Type == "ldap" {
|
||||||
sessionCookie.Provider = "ldap"
|
sessionCookie.Provider = "ldap"
|
||||||
}
|
}
|
||||||
@@ -258,6 +284,13 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
|||||||
Provider: "local",
|
Provider: "local",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if user.Attributes.Name != "" {
|
||||||
|
sessionCookie.Name = user.Attributes.Name
|
||||||
|
}
|
||||||
|
if user.Attributes.Email != "" {
|
||||||
|
sessionCookie.Email = user.Attributes.Email
|
||||||
|
}
|
||||||
|
|
||||||
tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
|
tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
|
||||||
|
|
||||||
err = controller.auth.CreateSessionCookie(c, &sessionCookie)
|
err = controller.auth.CreateSessionCookie(c, &sessionCookie)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path"
|
"path"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -36,6 +35,23 @@ func TestUserController(t *testing.T) {
|
|||||||
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
|
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
|
||||||
TotpSecret: "JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK",
|
TotpSecret: "JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Username: "attruser",
|
||||||
|
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
|
||||||
|
Attributes: config.UserAttributes{
|
||||||
|
Name: "Alice Smith",
|
||||||
|
Email: "alice@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Username: "attrtotpuser",
|
||||||
|
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
|
||||||
|
TotpSecret: "JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK",
|
||||||
|
Attributes: config.UserAttributes{
|
||||||
|
Name: "Bob Jones",
|
||||||
|
Email: "bob@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
SessionExpiry: 10, // 10 seconds, useful for testing
|
SessionExpiry: 10, // 10 seconds, useful for testing
|
||||||
CookieDomain: "example.com",
|
CookieDomain: "example.com",
|
||||||
@@ -273,6 +289,64 @@ func TestUserController(t *testing.T) {
|
|||||||
assert.Contains(t, recorder.Body.String(), "Too many failed TOTP attempts.")
|
assert.Contains(t, recorder.Body.String(), "Too many failed TOTP attempts.")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
description: "Login uses name and email from user attributes",
|
||||||
|
middlewares: []gin.HandlerFunc{},
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
loginReq := controller.LoginRequest{Username: "attruser", Password: "password"}
|
||||||
|
body, err := json.Marshal(loginReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(body)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
require.Equal(t, 200, recorder.Code)
|
||||||
|
cookies := recorder.Result().Cookies()
|
||||||
|
require.Len(t, cookies, 1)
|
||||||
|
assert.Equal(t, "tinyauth-session", cookies[0].Name)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Login with TOTP uses name and email from user attributes in pending session",
|
||||||
|
middlewares: []gin.HandlerFunc{},
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
loginReq := controller.LoginRequest{Username: "attrtotpuser", Password: "password"}
|
||||||
|
body, err := json.Marshal(loginReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(body)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
require.Equal(t, 200, recorder.Code)
|
||||||
|
var res map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &res))
|
||||||
|
assert.Equal(t, true, res["totpPending"])
|
||||||
|
require.Len(t, recorder.Result().Cookies(), 1)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "TOTP completion uses name and email from user attributes",
|
||||||
|
middlewares: []gin.HandlerFunc{},
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
code, err := totp.GenerateCode("JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK", time.Now())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
totpReq := controller.TotpRequest{Code: code}
|
||||||
|
body, err := json.Marshal(totpReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(body)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
require.Equal(t, 200, recorder.Code)
|
||||||
|
cookies := recorder.Result().Cookies()
|
||||||
|
require.Len(t, cookies, 1)
|
||||||
|
assert.Equal(t, "tinyauth-session", cookies[0].Name)
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
oauthBrokerCfgs := make(map[string]config.OAuthServiceConfig)
|
oauthBrokerCfgs := make(map[string]config.OAuthServiceConfig)
|
||||||
@@ -305,9 +379,31 @@ func TestUserController(t *testing.T) {
|
|||||||
authService.ClearRateLimitsTestingOnly()
|
authService.ClearRateLimitsTestingOnly()
|
||||||
}
|
}
|
||||||
|
|
||||||
setTotpMiddlewareOverrides := []string{
|
setTotpMiddlewareOverrides := map[string]config.UserContext{
|
||||||
"Should be able to login with totp",
|
"Should be able to login with totp": {
|
||||||
"Totp should rate limit on multiple invalid attempts",
|
Username: "totpuser",
|
||||||
|
Name: "Totpuser",
|
||||||
|
Email: "totpuser@example.com",
|
||||||
|
Provider: "local",
|
||||||
|
TotpPending: true,
|
||||||
|
TotpEnabled: true,
|
||||||
|
},
|
||||||
|
"Totp should rate limit on multiple invalid attempts": {
|
||||||
|
Username: "totpuser",
|
||||||
|
Name: "Totpuser",
|
||||||
|
Email: "totpuser@example.com",
|
||||||
|
Provider: "local",
|
||||||
|
TotpPending: true,
|
||||||
|
TotpEnabled: true,
|
||||||
|
},
|
||||||
|
"TOTP completion uses name and email from user attributes": {
|
||||||
|
Username: "attrtotpuser",
|
||||||
|
Name: "Bob Jones",
|
||||||
|
Email: "bob@example.com",
|
||||||
|
Provider: "local",
|
||||||
|
TotpPending: true,
|
||||||
|
TotpEnabled: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
@@ -321,18 +417,10 @@ func TestUserController(t *testing.T) {
|
|||||||
|
|
||||||
// Gin is stupid and doesn't allow setting a middleware after the groups
|
// Gin is stupid and doesn't allow setting a middleware after the groups
|
||||||
// so we need to do some stupid overrides here
|
// so we need to do some stupid overrides here
|
||||||
if slices.Contains(setTotpMiddlewareOverrides, test.description) {
|
if ctx, ok := setTotpMiddlewareOverrides[test.description]; ok {
|
||||||
// Assuming the cookie is set, it should be picked up by the
|
ctx := ctx
|
||||||
// context middleware
|
|
||||||
router.Use(func(c *gin.Context) {
|
router.Use(func(c *gin.Context) {
|
||||||
c.Set("context", &config.UserContext{
|
c.Set("context", &ctx)
|
||||||
Username: "totpuser",
|
|
||||||
Name: "Totpuser",
|
|
||||||
Email: "totpuser@example.com",
|
|
||||||
Provider: "local",
|
|
||||||
TotpPending: true,
|
|
||||||
TotpEnabled: true,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func (controller *WellKnownController) OpenIDConnectConfiguration(c *gin.Context
|
|||||||
SubjectTypesSupported: []string{"pairwise"},
|
SubjectTypesSupported: []string{"pairwise"},
|
||||||
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
||||||
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
|
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
|
||||||
ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups"},
|
ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups", "phone_number", "phone_number_verified", "address", "given_name", "family_name", "middle_name", "nickname", "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale"},
|
||||||
ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc",
|
ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc",
|
||||||
RequestParameterSupported: true,
|
RequestParameterSupported: true,
|
||||||
RequestObjectSigningAlgValuesSupported: []string{"none"},
|
RequestObjectSigningAlgValuesSupported: []string{"none"},
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func TestWellKnownController(t *testing.T) {
|
|||||||
SubjectTypesSupported: []string{"pairwise"},
|
SubjectTypesSupported: []string{"pairwise"},
|
||||||
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
||||||
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
|
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
|
||||||
ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups"},
|
ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups", "phone_number", "phone_number_verified", "address", "given_name", "family_name", "middle_name", "nickname", "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale"},
|
||||||
ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc",
|
ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc",
|
||||||
RequestParameterSupported: true,
|
RequestParameterSupported: true,
|
||||||
RequestObjectSigningAlgValuesSupported: []string{"none"},
|
RequestObjectSigningAlgValuesSupported: []string{"none"},
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var ldapGroups []string
|
var ldapGroups []string
|
||||||
|
var localAttributes config.UserAttributes
|
||||||
|
|
||||||
if cookie.Provider == "ldap" {
|
if cookie.Provider == "ldap" {
|
||||||
ldapUser, err := m.auth.GetLdapUser(userSearch.Username)
|
ldapUser, err := m.auth.GetLdapUser(userSearch.Username)
|
||||||
@@ -112,6 +113,11 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
|||||||
ldapGroups = ldapUser.Groups
|
ldapGroups = ldapUser.Groups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cookie.Provider == "local" {
|
||||||
|
localUser := m.auth.GetLocalUser(cookie.Username)
|
||||||
|
localAttributes = localUser.Attributes
|
||||||
|
}
|
||||||
|
|
||||||
m.auth.RefreshSessionCookie(c)
|
m.auth.RefreshSessionCookie(c)
|
||||||
c.Set("context", &config.UserContext{
|
c.Set("context", &config.UserContext{
|
||||||
Username: cookie.Username,
|
Username: cookie.Username,
|
||||||
@@ -120,6 +126,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
|||||||
Provider: cookie.Provider,
|
Provider: cookie.Provider,
|
||||||
IsLoggedIn: true,
|
IsLoggedIn: true,
|
||||||
LdapGroups: strings.Join(ldapGroups, ","),
|
LdapGroups: strings.Join(ldapGroups, ","),
|
||||||
|
Attributes: localAttributes,
|
||||||
})
|
})
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
@@ -202,13 +209,23 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
name := utils.Capitalize(user.Username)
|
||||||
|
if user.Attributes.Name != "" {
|
||||||
|
name = user.Attributes.Name
|
||||||
|
}
|
||||||
|
email := utils.CompileUserEmail(user.Username, m.config.CookieDomain)
|
||||||
|
if user.Attributes.Email != "" {
|
||||||
|
email = user.Attributes.Email
|
||||||
|
}
|
||||||
|
|
||||||
c.Set("context", &config.UserContext{
|
c.Set("context", &config.UserContext{
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Name: utils.Capitalize(user.Username),
|
Name: name,
|
||||||
Email: utils.CompileUserEmail(user.Username, m.config.CookieDomain),
|
Email: email,
|
||||||
Provider: "local",
|
Provider: "local",
|
||||||
IsLoggedIn: true,
|
IsLoggedIn: true,
|
||||||
IsBasicAuth: true,
|
IsBasicAuth: true,
|
||||||
|
Attributes: user.Attributes,
|
||||||
})
|
})
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -34,6 +34,19 @@ type OidcUserinfo struct {
|
|||||||
Email string
|
Email string
|
||||||
Groups string
|
Groups string
|
||||||
UpdatedAt int64
|
UpdatedAt int64
|
||||||
|
GivenName string
|
||||||
|
FamilyName string
|
||||||
|
MiddleName string
|
||||||
|
Nickname string
|
||||||
|
Profile string
|
||||||
|
Picture string
|
||||||
|
Website string
|
||||||
|
Gender string
|
||||||
|
Birthdate string
|
||||||
|
Zoneinfo string
|
||||||
|
Locale string
|
||||||
|
PhoneNumber string
|
||||||
|
Address string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
|
|||||||
@@ -124,11 +124,24 @@ INSERT INTO "oidc_userinfo" (
|
|||||||
"preferred_username",
|
"preferred_username",
|
||||||
"email",
|
"email",
|
||||||
"groups",
|
"groups",
|
||||||
"updated_at"
|
"updated_at",
|
||||||
|
"given_name",
|
||||||
|
"family_name",
|
||||||
|
"middle_name",
|
||||||
|
"nickname",
|
||||||
|
"profile",
|
||||||
|
"picture",
|
||||||
|
"website",
|
||||||
|
"gender",
|
||||||
|
"birthdate",
|
||||||
|
"zoneinfo",
|
||||||
|
"locale",
|
||||||
|
"phone_number",
|
||||||
|
"address"
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, ?
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||||
)
|
)
|
||||||
RETURNING sub, name, preferred_username, email, "groups", updated_at
|
RETURNING sub, name, preferred_username, email, "groups", updated_at, given_name, family_name, middle_name, nickname, profile, picture, website, gender, birthdate, zoneinfo, locale, phone_number, address
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateOidcUserInfoParams struct {
|
type CreateOidcUserInfoParams struct {
|
||||||
@@ -138,6 +151,19 @@ type CreateOidcUserInfoParams struct {
|
|||||||
Email string
|
Email string
|
||||||
Groups string
|
Groups string
|
||||||
UpdatedAt int64
|
UpdatedAt int64
|
||||||
|
GivenName string
|
||||||
|
FamilyName string
|
||||||
|
MiddleName string
|
||||||
|
Nickname string
|
||||||
|
Profile string
|
||||||
|
Picture string
|
||||||
|
Website string
|
||||||
|
Gender string
|
||||||
|
Birthdate string
|
||||||
|
Zoneinfo string
|
||||||
|
Locale string
|
||||||
|
PhoneNumber string
|
||||||
|
Address string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfoParams) (OidcUserinfo, error) {
|
func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfoParams) (OidcUserinfo, error) {
|
||||||
@@ -148,6 +174,19 @@ func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfo
|
|||||||
arg.Email,
|
arg.Email,
|
||||||
arg.Groups,
|
arg.Groups,
|
||||||
arg.UpdatedAt,
|
arg.UpdatedAt,
|
||||||
|
arg.GivenName,
|
||||||
|
arg.FamilyName,
|
||||||
|
arg.MiddleName,
|
||||||
|
arg.Nickname,
|
||||||
|
arg.Profile,
|
||||||
|
arg.Picture,
|
||||||
|
arg.Website,
|
||||||
|
arg.Gender,
|
||||||
|
arg.Birthdate,
|
||||||
|
arg.Zoneinfo,
|
||||||
|
arg.Locale,
|
||||||
|
arg.PhoneNumber,
|
||||||
|
arg.Address,
|
||||||
)
|
)
|
||||||
var i OidcUserinfo
|
var i OidcUserinfo
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -157,6 +196,19 @@ func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfo
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Groups,
|
&i.Groups,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.GivenName,
|
||||||
|
&i.FamilyName,
|
||||||
|
&i.MiddleName,
|
||||||
|
&i.Nickname,
|
||||||
|
&i.Profile,
|
||||||
|
&i.Picture,
|
||||||
|
&i.Website,
|
||||||
|
&i.Gender,
|
||||||
|
&i.Birthdate,
|
||||||
|
&i.Zoneinfo,
|
||||||
|
&i.Locale,
|
||||||
|
&i.PhoneNumber,
|
||||||
|
&i.Address,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -456,7 +508,7 @@ func (q *Queries) GetOidcTokenBySub(ctx context.Context, sub string) (OidcToken,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getOidcUserInfo = `-- name: GetOidcUserInfo :one
|
const getOidcUserInfo = `-- name: GetOidcUserInfo :one
|
||||||
SELECT sub, name, preferred_username, email, "groups", updated_at FROM "oidc_userinfo"
|
SELECT sub, name, preferred_username, email, "groups", updated_at, given_name, family_name, middle_name, nickname, profile, picture, website, gender, birthdate, zoneinfo, locale, phone_number, address FROM "oidc_userinfo"
|
||||||
WHERE "sub" = ?
|
WHERE "sub" = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -470,6 +522,19 @@ func (q *Queries) GetOidcUserInfo(ctx context.Context, sub string) (OidcUserinfo
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Groups,
|
&i.Groups,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.GivenName,
|
||||||
|
&i.FamilyName,
|
||||||
|
&i.MiddleName,
|
||||||
|
&i.Nickname,
|
||||||
|
&i.Profile,
|
||||||
|
&i.Picture,
|
||||||
|
&i.Website,
|
||||||
|
&i.Gender,
|
||||||
|
&i.Birthdate,
|
||||||
|
&i.Zoneinfo,
|
||||||
|
&i.Locale,
|
||||||
|
&i.PhoneNumber,
|
||||||
|
&i.Address,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
SupportedScopes = []string{"openid", "profile", "email", "groups"}
|
SupportedScopes = []string{"openid", "profile", "email", "phone", "address", "groups"}
|
||||||
SupportedResponseTypes = []string{"code"}
|
SupportedResponseTypes = []string{"code"}
|
||||||
SupportedGrantTypes = []string{"authorization_code", "refresh_token"}
|
SupportedGrantTypes = []string{"authorization_code", "refresh_token"}
|
||||||
)
|
)
|
||||||
@@ -48,6 +48,17 @@ type ClaimSet struct {
|
|||||||
Iat int64 `json:"iat"`
|
Iat int64 `json:"iat"`
|
||||||
Exp int64 `json:"exp"`
|
Exp int64 `json:"exp"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
|
GivenName string `json:"given_name,omitempty"`
|
||||||
|
FamilyName string `json:"family_name,omitempty"`
|
||||||
|
MiddleName string `json:"middle_name,omitempty"`
|
||||||
|
Nickname string `json:"nickname,omitempty"`
|
||||||
|
Profile string `json:"profile,omitempty"`
|
||||||
|
Picture string `json:"picture,omitempty"`
|
||||||
|
Website string `json:"website,omitempty"`
|
||||||
|
Gender string `json:"gender,omitempty"`
|
||||||
|
Birthdate string `json:"birthdate,omitempty"`
|
||||||
|
Zoneinfo string `json:"zoneinfo,omitempty"`
|
||||||
|
Locale string `json:"locale,omitempty"`
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
EmailVerified bool `json:"email_verified,omitempty"`
|
EmailVerified bool `json:"email_verified,omitempty"`
|
||||||
PreferredUsername string `json:"preferred_username,omitempty"`
|
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||||
@@ -56,13 +67,27 @@ type ClaimSet struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserinfoResponse struct {
|
type UserinfoResponse struct {
|
||||||
Sub string `json:"sub"`
|
Sub string `json:"sub"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Email string `json:"email,omitempty"`
|
GivenName string `json:"given_name,omitempty"`
|
||||||
PreferredUsername string `json:"preferred_username,omitempty"`
|
FamilyName string `json:"family_name,omitempty"`
|
||||||
Groups []string `json:"groups,omitempty"`
|
MiddleName string `json:"middle_name,omitempty"`
|
||||||
EmailVerified bool `json:"email_verified,omitempty"`
|
Nickname string `json:"nickname,omitempty"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
Profile string `json:"profile,omitempty"`
|
||||||
|
Picture string `json:"picture,omitempty"`
|
||||||
|
Website string `json:"website,omitempty"`
|
||||||
|
Gender string `json:"gender,omitempty"`
|
||||||
|
Birthdate string `json:"birthdate,omitempty"`
|
||||||
|
Zoneinfo string `json:"zoneinfo,omitempty"`
|
||||||
|
Locale string `json:"locale,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||||
|
Groups []string `json:"groups,omitempty"`
|
||||||
|
EmailVerified bool `json:"email_verified,omitempty"`
|
||||||
|
PhoneNumber string `json:"phone_number,omitempty"`
|
||||||
|
PhoneNumberVerified *bool `json:"phone_number_verified,omitempty"`
|
||||||
|
Address *config.AddressClaim `json:"address,omitempty"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TokenResponse struct {
|
type TokenResponse struct {
|
||||||
@@ -342,12 +367,30 @@ func (service *OIDCService) StoreCode(c *gin.Context, sub string, code string, r
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (service *OIDCService) StoreUserinfo(c *gin.Context, sub string, userContext config.UserContext, req AuthorizeRequest) error {
|
func (service *OIDCService) StoreUserinfo(c *gin.Context, sub string, userContext config.UserContext, req AuthorizeRequest) error {
|
||||||
|
addressJSON, err := json.Marshal(userContext.Attributes.Address)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
userInfoParams := repository.CreateOidcUserInfoParams{
|
userInfoParams := repository.CreateOidcUserInfoParams{
|
||||||
Sub: sub,
|
Sub: sub,
|
||||||
Name: userContext.Name,
|
Name: userContext.Name,
|
||||||
Email: userContext.Email,
|
Email: userContext.Email,
|
||||||
PreferredUsername: userContext.Username,
|
PreferredUsername: userContext.Username,
|
||||||
UpdatedAt: time.Now().Unix(),
|
UpdatedAt: time.Now().Unix(),
|
||||||
|
GivenName: userContext.Attributes.GivenName,
|
||||||
|
FamilyName: userContext.Attributes.FamilyName,
|
||||||
|
MiddleName: userContext.Attributes.MiddleName,
|
||||||
|
Nickname: userContext.Attributes.Nickname,
|
||||||
|
Profile: userContext.Attributes.Profile,
|
||||||
|
Picture: userContext.Attributes.Picture,
|
||||||
|
Website: userContext.Attributes.Website,
|
||||||
|
Gender: userContext.Attributes.Gender,
|
||||||
|
Birthdate: userContext.Attributes.Birthdate,
|
||||||
|
Zoneinfo: userContext.Attributes.Zoneinfo,
|
||||||
|
Locale: userContext.Attributes.Locale,
|
||||||
|
PhoneNumber: userContext.Attributes.PhoneNumber,
|
||||||
|
Address: string(addressJSON),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tinyauth will pass through the groups it got from an LDAP or an OIDC server
|
// Tinyauth will pass through the groups it got from an LDAP or an OIDC server
|
||||||
@@ -359,7 +402,7 @@ func (service *OIDCService) StoreUserinfo(c *gin.Context, sub string, userContex
|
|||||||
userInfoParams.Groups = userContext.OAuthGroups
|
userInfoParams.Groups = userContext.OAuthGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := service.queries.CreateOidcUserInfo(c, userInfoParams)
|
_, err = service.queries.CreateOidcUserInfo(c, userInfoParams)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -637,12 +680,22 @@ func (service *OIDCService) CompileUserinfo(user repository.OidcUserinfo, scope
|
|||||||
if slices.Contains(scopes, "profile") {
|
if slices.Contains(scopes, "profile") {
|
||||||
userInfo.Name = user.Name
|
userInfo.Name = user.Name
|
||||||
userInfo.PreferredUsername = user.PreferredUsername
|
userInfo.PreferredUsername = user.PreferredUsername
|
||||||
|
userInfo.GivenName = user.GivenName
|
||||||
|
userInfo.FamilyName = user.FamilyName
|
||||||
|
userInfo.MiddleName = user.MiddleName
|
||||||
|
userInfo.Nickname = user.Nickname
|
||||||
|
userInfo.Profile = user.Profile
|
||||||
|
userInfo.Picture = user.Picture
|
||||||
|
userInfo.Website = user.Website
|
||||||
|
userInfo.Gender = user.Gender
|
||||||
|
userInfo.Birthdate = user.Birthdate
|
||||||
|
userInfo.Zoneinfo = user.Zoneinfo
|
||||||
|
userInfo.Locale = user.Locale
|
||||||
}
|
}
|
||||||
|
|
||||||
if slices.Contains(scopes, "email") {
|
if slices.Contains(scopes, "email") {
|
||||||
userInfo.Email = user.Email
|
userInfo.Email = user.Email
|
||||||
// We can set this as a configuration option in the future but for now it's a good idea to assume it's true
|
userInfo.EmailVerified = user.Email != ""
|
||||||
userInfo.EmailVerified = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if slices.Contains(scopes, "groups") {
|
if slices.Contains(scopes, "groups") {
|
||||||
@@ -653,6 +706,19 @@ func (service *OIDCService) CompileUserinfo(user repository.OidcUserinfo, scope
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if slices.Contains(scopes, "phone") {
|
||||||
|
userInfo.PhoneNumber = user.PhoneNumber
|
||||||
|
verified := user.PhoneNumber != ""
|
||||||
|
userInfo.PhoneNumberVerified = &verified
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(scopes, "address") {
|
||||||
|
var addr config.AddressClaim
|
||||||
|
if err := json.Unmarshal([]byte(user.Address), &addr); err == nil {
|
||||||
|
userInfo.Address = &addr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return userInfo
|
return userInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package service_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||||
|
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||||
|
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestUser() repository.OidcUserinfo {
|
||||||
|
addr := config.AddressClaim{
|
||||||
|
Formatted: "123 Main St",
|
||||||
|
StreetAddress: "123 Main St",
|
||||||
|
Locality: "Springfield",
|
||||||
|
Region: "IL",
|
||||||
|
PostalCode: "62701",
|
||||||
|
Country: "US",
|
||||||
|
}
|
||||||
|
addrJSON, _ := json.Marshal(addr)
|
||||||
|
|
||||||
|
return repository.OidcUserinfo{
|
||||||
|
Sub: "test-sub",
|
||||||
|
Name: "Test User",
|
||||||
|
PreferredUsername: "testuser",
|
||||||
|
Email: "test@example.com",
|
||||||
|
Groups: "admins,users",
|
||||||
|
UpdatedAt: 1234567890,
|
||||||
|
GivenName: "Test",
|
||||||
|
FamilyName: "User",
|
||||||
|
MiddleName: "M",
|
||||||
|
Nickname: "testy",
|
||||||
|
Profile: "https://example.com/testuser",
|
||||||
|
Picture: "https://example.com/testuser.jpg",
|
||||||
|
Website: "https://testuser.example.com",
|
||||||
|
Gender: "male",
|
||||||
|
Birthdate: "1990-01-01",
|
||||||
|
Zoneinfo: "America/Chicago",
|
||||||
|
Locale: "en-US",
|
||||||
|
PhoneNumber: "+15555550100",
|
||||||
|
Address: string(addrJSON),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompileUserinfo(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
svc := service.NewOIDCService(service.OIDCServiceConfig{
|
||||||
|
PrivateKeyPath: dir + "/key.pem",
|
||||||
|
PublicKeyPath: dir + "/key.pub",
|
||||||
|
Issuer: "https://tinyauth.example.com",
|
||||||
|
SessionExpiry: 3600,
|
||||||
|
}, nil)
|
||||||
|
require.NoError(t, svc.Init())
|
||||||
|
|
||||||
|
type testCase struct {
|
||||||
|
description string
|
||||||
|
mutate func(u *repository.OidcUserinfo)
|
||||||
|
scope string
|
||||||
|
run func(t *testing.T, info service.UserinfoResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []testCase{
|
||||||
|
{
|
||||||
|
description: "openid scope only returns sub and updated_at",
|
||||||
|
scope: "openid",
|
||||||
|
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||||
|
assert.Equal(t, "test-sub", info.Sub)
|
||||||
|
assert.Equal(t, int64(1234567890), info.UpdatedAt)
|
||||||
|
assert.Empty(t, info.Name)
|
||||||
|
assert.Empty(t, info.Email)
|
||||||
|
assert.Nil(t, info.Groups)
|
||||||
|
assert.Nil(t, info.PhoneNumberVerified)
|
||||||
|
assert.Nil(t, info.Address)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "profile scope returns all profile fields",
|
||||||
|
scope: "openid,profile",
|
||||||
|
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||||
|
assert.Equal(t, "Test User", info.Name)
|
||||||
|
assert.Equal(t, "testuser", info.PreferredUsername)
|
||||||
|
assert.Equal(t, "Test", info.GivenName)
|
||||||
|
assert.Equal(t, "User", info.FamilyName)
|
||||||
|
assert.Equal(t, "M", info.MiddleName)
|
||||||
|
assert.Equal(t, "testy", info.Nickname)
|
||||||
|
assert.Equal(t, "https://example.com/testuser", info.Profile)
|
||||||
|
assert.Equal(t, "https://example.com/testuser.jpg", info.Picture)
|
||||||
|
assert.Equal(t, "https://testuser.example.com", info.Website)
|
||||||
|
assert.Equal(t, "male", info.Gender)
|
||||||
|
assert.Equal(t, "1990-01-01", info.Birthdate)
|
||||||
|
assert.Equal(t, "America/Chicago", info.Zoneinfo)
|
||||||
|
assert.Equal(t, "en-US", info.Locale)
|
||||||
|
assert.Empty(t, info.Email)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "email scope sets email and email_verified true when email present",
|
||||||
|
scope: "openid,email",
|
||||||
|
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||||
|
assert.Equal(t, "test@example.com", info.Email)
|
||||||
|
assert.True(t, info.EmailVerified)
|
||||||
|
assert.Empty(t, info.Name)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "email scope sets email_verified false when email absent",
|
||||||
|
scope: "openid,email",
|
||||||
|
mutate: func(u *repository.OidcUserinfo) { u.Email = "" },
|
||||||
|
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||||
|
assert.Empty(t, info.Email)
|
||||||
|
assert.False(t, info.EmailVerified)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "phone scope sets phone_number_verified true when phone present",
|
||||||
|
scope: "openid,phone",
|
||||||
|
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||||
|
assert.Equal(t, "+15555550100", info.PhoneNumber)
|
||||||
|
require.NotNil(t, info.PhoneNumberVerified)
|
||||||
|
assert.True(t, *info.PhoneNumberVerified)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "phone scope sets phone_number_verified false when phone absent",
|
||||||
|
scope: "openid,phone",
|
||||||
|
mutate: func(u *repository.OidcUserinfo) { u.PhoneNumber = "" },
|
||||||
|
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||||
|
require.NotNil(t, info.PhoneNumberVerified)
|
||||||
|
assert.False(t, *info.PhoneNumberVerified)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "address scope returns parsed address",
|
||||||
|
scope: "openid,address",
|
||||||
|
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||||
|
require.NotNil(t, info.Address)
|
||||||
|
assert.Equal(t, "123 Main St", info.Address.Formatted)
|
||||||
|
assert.Equal(t, "123 Main St", info.Address.StreetAddress)
|
||||||
|
assert.Equal(t, "Springfield", info.Address.Locality)
|
||||||
|
assert.Equal(t, "IL", info.Address.Region)
|
||||||
|
assert.Equal(t, "62701", info.Address.PostalCode)
|
||||||
|
assert.Equal(t, "US", info.Address.Country)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "address scope with invalid JSON omits address",
|
||||||
|
scope: "openid,address",
|
||||||
|
mutate: func(u *repository.OidcUserinfo) { u.Address = "not-valid-json" },
|
||||||
|
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||||
|
assert.Nil(t, info.Address)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "groups scope returns split groups",
|
||||||
|
scope: "openid,groups",
|
||||||
|
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||||
|
assert.Equal(t, []string{"admins", "users"}, info.Groups)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "groups scope returns empty slice when no groups",
|
||||||
|
scope: "openid,groups",
|
||||||
|
mutate: func(u *repository.OidcUserinfo) { u.Groups = "" },
|
||||||
|
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||||
|
assert.Equal(t, []string{}, info.Groups)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "all scopes return all fields",
|
||||||
|
scope: "openid,profile,email,phone,address,groups",
|
||||||
|
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||||
|
assert.Equal(t, "Test User", info.Name)
|
||||||
|
assert.Equal(t, "test@example.com", info.Email)
|
||||||
|
assert.Equal(t, "+15555550100", info.PhoneNumber)
|
||||||
|
require.NotNil(t, info.PhoneNumberVerified)
|
||||||
|
assert.True(t, *info.PhoneNumberVerified)
|
||||||
|
require.NotNil(t, info.Address)
|
||||||
|
assert.Equal(t, "Springfield", info.Address.Locality)
|
||||||
|
assert.Equal(t, []string{"admins", "users"}, info.Groups)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.description, func(t *testing.T) {
|
||||||
|
user := newTestUser()
|
||||||
|
if test.mutate != nil {
|
||||||
|
test.mutate(&user)
|
||||||
|
}
|
||||||
|
info := svc.CompileUserinfo(user, test.scope)
|
||||||
|
test.run(t, info)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ParseUsers(usersStr []string) ([]config.User, error) {
|
func ParseUsers(usersStr []string, userAttributes map[string]config.UserAttributes) ([]config.User, error) {
|
||||||
var users []config.User
|
var users []config.User
|
||||||
|
|
||||||
if len(usersStr) == 0 {
|
if len(usersStr) == 0 {
|
||||||
@@ -24,13 +24,16 @@ func ParseUsers(usersStr []string) ([]config.User, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return []config.User{}, err
|
return []config.User{}, err
|
||||||
}
|
}
|
||||||
|
if attrs, ok := userAttributes[parsed.Username]; ok {
|
||||||
|
parsed.Attributes = attrs
|
||||||
|
}
|
||||||
users = append(users, parsed)
|
users = append(users, parsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
return users, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetUsers(usersCfg []string, usersPath string) ([]config.User, error) {
|
func GetUsers(usersCfg []string, usersPath string, userAttributes map[string]config.UserAttributes) ([]config.User, error) {
|
||||||
var usersStr []string
|
var usersStr []string
|
||||||
|
|
||||||
if len(usersCfg) == 0 && usersPath == "" {
|
if len(usersCfg) == 0 && usersPath == "" {
|
||||||
@@ -59,7 +62,7 @@ func GetUsers(usersCfg []string, usersPath string) ([]config.User, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ParseUsers(usersStr)
|
return ParseUsers(usersStr, userAttributes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseUser(userStr string) (config.User, error) {
|
func ParseUser(userStr string) (config.User, error) {
|
||||||
|
|||||||
@@ -4,122 +4,117 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||||
|
|
||||||
"gotest.tools/v3/assert"
|
"gotest.tools/v3/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetUsers(t *testing.T) {
|
func TestGetUsers(t *testing.T) {
|
||||||
|
hash := "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G"
|
||||||
|
|
||||||
// Setup
|
// Setup
|
||||||
file, err := os.Create("/tmp/tinyauth_users_test.txt")
|
file, err := os.Create("/tmp/tinyauth_users_test.txt")
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
|
|
||||||
_, err = file.WriteString(" user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G \n user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G ") // Spacing is on purpose
|
_, err = file.WriteString(" user1:" + hash + " \n user2:" + hash + " ") // Spacing is on purpose
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
|
|
||||||
err = file.Close()
|
err = file.Close()
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
defer os.Remove("/tmp/tinyauth_users_test.txt")
|
defer os.Remove("/tmp/tinyauth_users_test.txt")
|
||||||
|
|
||||||
// Test file
|
noAttrs := map[string]config.UserAttributes{}
|
||||||
users, err := utils.GetUsers([]string{}, "/tmp/tinyauth_users_test.txt")
|
|
||||||
|
// Test file only
|
||||||
|
users, err := utils.GetUsers([]string{}, "/tmp/tinyauth_users_test.txt", noAttrs)
|
||||||
|
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 2, len(users))
|
assert.Equal(t, 2, len(users))
|
||||||
|
|
||||||
assert.Equal(t, "user1", users[0].Username)
|
assert.Equal(t, "user1", users[0].Username)
|
||||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password)
|
assert.Equal(t, hash, users[0].Password)
|
||||||
assert.Equal(t, "user2", users[1].Username)
|
assert.Equal(t, "user2", users[1].Username)
|
||||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password)
|
assert.Equal(t, hash, users[1].Password)
|
||||||
|
|
||||||
// Test config
|
// Test inline config only
|
||||||
users, err = utils.GetUsers([]string{"user3:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", "user4:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G"}, "")
|
users, err = utils.GetUsers([]string{"user3:" + hash, "user4:" + hash}, "", noAttrs)
|
||||||
|
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 2, len(users))
|
assert.Equal(t, 2, len(users))
|
||||||
|
|
||||||
assert.Equal(t, "user3", users[0].Username)
|
assert.Equal(t, "user3", users[0].Username)
|
||||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password)
|
|
||||||
assert.Equal(t, "user4", users[1].Username)
|
assert.Equal(t, "user4", users[1].Username)
|
||||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password)
|
|
||||||
|
|
||||||
// Test both
|
// Test both
|
||||||
users, err = utils.GetUsers([]string{"user5:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G"}, "/tmp/tinyauth_users_test.txt")
|
users, err = utils.GetUsers([]string{"user5:" + hash}, "/tmp/tinyauth_users_test.txt", noAttrs)
|
||||||
|
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 3, len(users))
|
assert.Equal(t, 3, len(users))
|
||||||
|
|
||||||
assert.Equal(t, "user5", users[0].Username)
|
usernames := map[string]bool{}
|
||||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password)
|
for _, u := range users {
|
||||||
assert.Equal(t, "user1", users[1].Username)
|
usernames[u.Username] = true
|
||||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password)
|
}
|
||||||
assert.Equal(t, "user2", users[2].Username)
|
assert.Assert(t, usernames["user1"])
|
||||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[2].Password)
|
assert.Assert(t, usernames["user2"])
|
||||||
|
assert.Assert(t, usernames["user5"])
|
||||||
|
|
||||||
|
// Test attributes applied from userAttributes map
|
||||||
|
attrs := map[string]config.UserAttributes{
|
||||||
|
"user1": {Name: "User One", Email: "user1@example.com"},
|
||||||
|
}
|
||||||
|
users, err = utils.GetUsers([]string{}, "/tmp/tinyauth_users_test.txt", attrs)
|
||||||
|
|
||||||
|
assert.NilError(t, err)
|
||||||
|
assert.Equal(t, 2, len(users))
|
||||||
|
|
||||||
|
for _, u := range users {
|
||||||
|
if u.Username == "user1" {
|
||||||
|
assert.Equal(t, "User One", u.Attributes.Name)
|
||||||
|
assert.Equal(t, "user1@example.com", u.Attributes.Email)
|
||||||
|
}
|
||||||
|
if u.Username == "user2" {
|
||||||
|
assert.Equal(t, "", u.Attributes.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Test empty
|
// Test empty
|
||||||
users, err = utils.GetUsers([]string{}, "")
|
users, err = utils.GetUsers([]string{}, "", noAttrs)
|
||||||
|
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 0, len(users))
|
assert.Equal(t, 0, len(users))
|
||||||
|
|
||||||
// Test non-existent file
|
// Test non-existent file
|
||||||
users, err = utils.GetUsers([]string{}, "/tmp/non_existent_file.txt")
|
users, err = utils.GetUsers([]string{}, "/tmp/non_existent_file.txt", noAttrs)
|
||||||
|
|
||||||
assert.ErrorContains(t, err, "no such file or directory")
|
assert.ErrorContains(t, err, "no such file or directory")
|
||||||
|
|
||||||
assert.Equal(t, 0, len(users))
|
assert.Equal(t, 0, len(users))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseUsers(t *testing.T) {
|
|
||||||
// Valid users
|
|
||||||
users, err := utils.ParseUsers([]string{"user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", "user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF"}) // user2 has TOTP
|
|
||||||
|
|
||||||
assert.NilError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, 2, len(users))
|
|
||||||
|
|
||||||
assert.Equal(t, "user1", users[0].Username)
|
|
||||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password)
|
|
||||||
assert.Equal(t, "", users[0].TotpSecret)
|
|
||||||
assert.Equal(t, "user2", users[1].Username)
|
|
||||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password)
|
|
||||||
assert.Equal(t, "ABCDEF", users[1].TotpSecret)
|
|
||||||
|
|
||||||
// Valid weirdly spaced users
|
|
||||||
users, err = utils.ParseUsers([]string{" user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G ", " user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF "}) // Spacing is on purpose
|
|
||||||
assert.NilError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, 2, len(users))
|
|
||||||
|
|
||||||
assert.Equal(t, "user1", users[0].Username)
|
|
||||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password)
|
|
||||||
assert.Equal(t, "", users[0].TotpSecret)
|
|
||||||
assert.Equal(t, "user2", users[1].Username)
|
|
||||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password)
|
|
||||||
assert.Equal(t, "ABCDEF", users[1].TotpSecret)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseUser(t *testing.T) {
|
func TestParseUser(t *testing.T) {
|
||||||
|
hash := "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G"
|
||||||
|
|
||||||
// Valid user without TOTP
|
// Valid user without TOTP
|
||||||
user, err := utils.ParseUser("user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G")
|
user, err := utils.ParseUser("user1:" + hash)
|
||||||
|
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, "user1", user.Username)
|
assert.Equal(t, "user1", user.Username)
|
||||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", user.Password)
|
assert.Equal(t, hash, user.Password)
|
||||||
assert.Equal(t, "", user.TotpSecret)
|
assert.Equal(t, "", user.TotpSecret)
|
||||||
|
|
||||||
// Valid user with TOTP
|
// Valid user with TOTP
|
||||||
user, err = utils.ParseUser("user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF")
|
user, err = utils.ParseUser("user2:" + hash + ":ABCDEF")
|
||||||
|
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, "user2", user.Username)
|
assert.Equal(t, "user2", user.Username)
|
||||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", user.Password)
|
assert.Equal(t, hash, user.Password)
|
||||||
assert.Equal(t, "ABCDEF", user.TotpSecret)
|
assert.Equal(t, "ABCDEF", user.TotpSecret)
|
||||||
|
|
||||||
// Valid user with $$ in password
|
// Valid user with $$ in password
|
||||||
|
|||||||
+15
-2
@@ -95,9 +95,22 @@ INSERT INTO "oidc_userinfo" (
|
|||||||
"preferred_username",
|
"preferred_username",
|
||||||
"email",
|
"email",
|
||||||
"groups",
|
"groups",
|
||||||
"updated_at"
|
"updated_at",
|
||||||
|
"given_name",
|
||||||
|
"family_name",
|
||||||
|
"middle_name",
|
||||||
|
"nickname",
|
||||||
|
"profile",
|
||||||
|
"picture",
|
||||||
|
"website",
|
||||||
|
"gender",
|
||||||
|
"birthdate",
|
||||||
|
"zoneinfo",
|
||||||
|
"locale",
|
||||||
|
"phone_number",
|
||||||
|
"address"
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, ?
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||||
)
|
)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
|||||||
+19
-6
@@ -22,10 +22,23 @@ CREATE TABLE IF NOT EXISTS "oidc_tokens" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "oidc_userinfo" (
|
CREATE TABLE IF NOT EXISTS "oidc_userinfo" (
|
||||||
"sub" TEXT NOT NULL UNIQUE PRIMARY KEY,
|
"sub" TEXT NOT NULL UNIQUE PRIMARY KEY,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
"preferred_username" TEXT NOT NULL,
|
"preferred_username" TEXT NOT NULL,
|
||||||
"email" TEXT NOT NULL,
|
"email" TEXT NOT NULL,
|
||||||
"groups" TEXT NOT NULL,
|
"groups" TEXT NOT NULL,
|
||||||
"updated_at" INTEGER NOT NULL
|
"updated_at" INTEGER NOT NULL,
|
||||||
|
"given_name" TEXT NOT NULL,
|
||||||
|
"family_name" TEXT NOT NULL,
|
||||||
|
"middle_name" TEXT NOT NULL,
|
||||||
|
"nickname" TEXT NOT NULL,
|
||||||
|
"profile" TEXT NOT NULL,
|
||||||
|
"picture" TEXT NOT NULL,
|
||||||
|
"website" TEXT NOT NULL,
|
||||||
|
"gender" TEXT NOT NULL,
|
||||||
|
"birthdate" TEXT NOT NULL,
|
||||||
|
"zoneinfo" TEXT NOT NULL,
|
||||||
|
"locale" TEXT NOT NULL,
|
||||||
|
"phone_number" TEXT NOT NULL,
|
||||||
|
"address" TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user