Compare commits

...

33 Commits

Author SHA1 Message Date
surtur 4d10510f5b
go: always defer adding headers
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-10 14:16:57 +02:00
surtur 882b7dfd28
go: add more logs on unauthorised access
All checks were successful
continuous-integration/drone/push Build is passing
* log details about unauthorised access
* return semantically correct 403 (instead of 401) on unauthorised access
* allow read-only admin access to "hibp breach details" endpoint
2023-09-10 14:12:13 +02:00
surtur 67165c82cc
tmpl(user-details): h3 -> span [skip ci] 2023-09-10 13:00:25 +02:00
surtur b97e47ed1b
go(app/settings): create a file for defaults
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-10 12:58:38 +02:00
surtur 35435da9a6
head.tmpl,go: set description+lang, rm meta CSP
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-10 12:47:35 +02:00
surtur de9c6d0196
ci: build release container using plugins/kaniko
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-10 12:30:37 +02:00
surtur fc3dc01229
release 0.0.1-rc.3
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2023-09-08 23:00:44 +02:00
surtur 1b457aa8c0
goreleaser: only build for arm64 linux atm [skip ci] 2023-09-08 22:59:36 +02:00
surtur 96c0b53493
go,tmpl: implement+activate validator
All checks were successful
continuous-integration/drone/push Build is passing
also ad initial password change:
* switch the password field type to `password`
* add a field for repeated password
2023-09-08 22:56:17 +02:00
surtur ff87c35dd1
head.tmpl: set 'og:description' meta tag
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-08 17:54:00 +02:00
surtur 1d159e4f64
go,tmpl: unify handling of CSP
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-08 17:48:51 +02:00
surtur 73915fcd98
fix(go): resolve signin/logout issues for all time
All checks were successful
continuous-integration/drone/push Build is passing
affects:
* app/settings
* app/server
* handlers
    * signin
    * signup
    * logout
    * home
    * middleware
2023-09-08 17:22:20 +02:00
surtur 83f0ec7e15
fix(go): set correct cookie params
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-04 21:02:06 +02:00
surtur 07d19e6b77
go: handle demoting admin to regular-user level
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-04 20:27:14 +02:00
surtur e10fdc5042
go: update last_login for users w/ finished setup
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-04 20:02:21 +02:00
surtur 1b2d860beb
fix(go,tmpl): solve the Chromium/Safari logout...
All checks were successful
continuous-integration/drone/push Build is passing
...issue by deleting the session cookie after successful password change
and forcing the user to re-authenticate.

additionally, split the InitialPasswordChange func into separate "GET"
and "POST" variants.
2023-09-04 19:21:01 +02:00
surtur e8515d9a89
chore(tmpl): alignment [skip ci] 2023-09-04 16:17:54 +02:00
surtur d0867f0686
tmpl: add text to home page [skip ci] 2023-09-04 16:17:14 +02:00
surtur fcea85e54b
go(sessionMiddleware): render err page on 401
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-04 15:31:11 +02:00
surtur fa1253a675
fix(go): set logout cache-control header+add check
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-04 15:22:17 +02:00
surtur 4e17a6c911
tmpl: reflect default value in checkbox [skip ci] 2023-09-04 15:19:20 +02:00
surtur 0c8f867316
chore(tmpl): code readability [skip ci] 2023-09-04 15:15:43 +02:00
surtur 5527caa3a8
fix(go): prevent panic on manage/api-keys
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-04 15:12:58 +02:00
surtur 5d494fca8d
go,tmpl(api-keys): add tooltips, disable buttons
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-04 15:00:41 +02:00
surtur 010e54168a
go(app/server): skip logging /assets visits
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-04 14:01:42 +02:00
surtur 15994c9d8f
tmpl(signin): add link tooltip [skip ci] 2023-09-04 13:59:07 +02:00
surtur 34babd8335
tmpl: indent input field params for clarity
[skip ci]
2023-09-04 13:45:36 +02:00
surtur 0cb77e096f
tmpl(signin): rm red borders on `invalid`
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-04 12:25:32 +02:00
surtur b1e2168023
fix(go,tmpl): require minlength on username/passwd
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-04 12:21:58 +02:00
surtur c10b4326b8
tmpl(signin): green -> plain border on username
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-04 11:59:57 +02:00
surtur fd2916e73e
fix(go): show LastLogin on `Manage Users` page
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-04 11:33:50 +02:00
surtur f4bd798821
fix(go): reject empty/same passwd on init change
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-04 11:28:23 +02:00
surtur 047471e6d4
tmpl: fix init user password not changing
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
the backend type was binding `new-password`, while the password input
field in the template was named `password`.
2023-09-04 10:21:29 +02:00
36 changed files with 1111 additions and 362 deletions

View File

@ -237,7 +237,7 @@ steps:
- name: kaniko publish
pull: always
image: docker.io/immawanderer/drone-kaniko:linux-amd64
image: docker.io/plugins/kaniko:1.7.5-kaniko1.9.1
settings:
dockerfile: Containerfile
context: .

View File

@ -9,10 +9,6 @@ archives:
# - name_template: '{{ .ProjectName }}-v{{ .Version }}-{{ .Os }}-{{ .Arch }}'
- name_template: '{{ .ProjectName }}_v{{ .Version }}'
meta: true
files:
- README.md
- LICENSE
- exampleConfig.dhall
before:
hooks:
- go generate .
@ -25,10 +21,10 @@ builds:
- -trimpath
goarch:
- amd64
- arm64
# - arm64
goos:
- linux
- freebsd
# - freebsd
ldflags:
- -s -w -X main.version={{ .Version }} -X main.commit={{ .Commit }}
mod_timestamp: '{{ .CommitTimestamp }}'
@ -38,9 +34,8 @@ release:
prerelease: true
skip_upload: false
extra_files:
- glob: ./README.md
- glob: ./LICENSE
- glob: ./exampleConfig.dhall
- glob: ./dist/pcmt_linux_amd64_v2/pcmt
mode: replace
gitea:
owner: mirre-mt
@ -53,6 +48,9 @@ snapshot:
checksum:
name_template: "{{ .ProjectName }}_{{ .Version }}_SHA512SUMS.txt"
algorithm: sha512
extra_files:
- glob: ./exampleConfig.dhall
- glob: ./dist/pcmt_linux_amd64_v2/pcmt
changelog:
use: git
sort: asc

View File

@ -75,7 +75,7 @@ func (a *App) SetupRoutes() error {
user := e.Group("/user", handlers.MiddlewareSession, xsrf)
user.GET("/initial-password-change", handlers.InitialPasswordChange())
user.POST("/initial-password-change", handlers.InitialPasswordChange())
user.POST("/initial-password-change", handlers.InitialPasswordChangePost())
user.GET("/hibp-search", handlers.GetSearchHIBP())
user.POST("/hibp-search", handlers.SearchHIBP())
user.GET("/hibp-breach-details/:name", handlers.ViewHIBP())
@ -92,11 +92,8 @@ func (a *App) SetupRoutes() error {
manage.POST("/users/:id/update", handlers.UpdateUser())
manage.POST("/users/:id/delete", handlers.DeleteUser())
e.GET("/logout", handlers.Logout())
e.GET("/logout", handlers.Logout(), xsrf)
e.POST("/logout", handlers.Logout(), handlers.MiddlewareSession, xsrf)
// administrative endpoints.
e.GET("/admin/*", handlers.Admin())
return nil
}

View File

@ -6,11 +6,14 @@ package app
import (
"encoding/hex"
"net/http"
"regexp"
"git.dotya.ml/mirre-mt/pcmt/modules/validation"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"golang.org/x/exp/slog"
"golang.org/x/time/rate"
)
@ -20,7 +23,22 @@ func (a *App) SetServerSettings() {
e.HideBanner = true
e.Use(middleware.Logger())
if a.setting.DefaultLoggerSkipAssets() {
re := regexp.MustCompile("^/(assets|static)(.*)|favicon.ico")
lC := middleware.DefaultLoggerConfig
lC.Skipper = func(c echo.Context) bool {
r := c.Request().URL.Path
slog.Debug("logger skipper", "path", r)
return re.MatchString(r)
}
e.Use(middleware.LoggerWithConfig(lC))
} else {
e.Use(middleware.Logger())
}
// e.Use(middleware.LoggerWithConfig(
// middleware.LoggerConfig{
// Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}",` +
@ -56,6 +74,8 @@ func (a *App) SetServerSettings() {
))
}
e.Validator = validation.New()
// TODO: add check for prometheus config setting.
// if true {
// // import "github.com/labstack/echo-contrib/prometheus"
@ -112,7 +132,9 @@ func (a *App) SetServerSettings() {
)
}
store.Options.Domain = a.setting.HTTPDomain()
store.Options.Path = "/"
// let the domain be set automatically based on where the app is running.
// store.Options.Domain = a.setting.HTTPDomain()
store.Options.HttpOnly = true
store.Options.SameSite = http.SameSiteStrictMode
store.Options.Secure = a.setting.HTTPSecure()
@ -120,7 +142,13 @@ func (a *App) SetServerSettings() {
e.Use(session.Middleware(store))
e.Use(middleware.Secure())
e.Use(
middleware.SecureWithConfig(
middleware.SecureConfig{
ContentSecurityPolicy: a.setting.HTTPCSP(),
},
),
)
if a.setting.HTTPGzipEnabled() {
e.Use(middleware.GzipWithConfig(middleware.GzipConfig{
@ -148,6 +176,7 @@ func (a *App) csrfConfig() echo.MiddlewareFunc {
CookieHTTPOnly: true,
CookieSameSite: http.SameSiteStrictMode,
CookieMaxAge: a.setting.SessionMaxAge(),
CookiePath: "/",
},
)
}

20
app/settings/api-keys.go Normal file
View File

@ -0,0 +1,20 @@
// Copyright 2023 wanderer <a_mirre at utb dot cz>
// SPDX-License-Identifier: AGPL-3.0-only
package settings
import (
"os"
)
func (s *Settings) setAPIKeys() {
if hibpK := os.Getenv("PCMT_HIBP_API_KEY"); hibpK != "" {
log.Info("setting HIBP API key from env var")
s.SetAPIKeyHIBP(hibpK)
}
if dehashedK := os.Getenv("PCMT_DEHASHED_API_KEY"); dehashedK != "" {
log.Info("setting dehashed.com API key from env var")
s.SetAPIKeyDehashed(dehashedK)
}
}

18
app/settings/defaults.go Normal file
View File

@ -0,0 +1,18 @@
// Copyright 2023 wanderer <a_mirre at utb dot cz>
// SPDX-License-Identifier: AGPL-3.0-only
package settings
import "time"
const (
defaultAppName = "pcmt"
defaultPort = 3000
defaultSessionMaxAge = 86400 // seconds.
defaultHTTPDomain = "localhost"
defaultCSP = "upgrade-insecure-requests; default-src 'self'; manifest-src 'self'; font-src 'self'; connect-src 'self'; script-src 'self'; style-src 'self'; object-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'"
defaultCSPDevel = "default-src 'self'; manifest-src 'self'; font-src 'self'; connect-src 'self' ws://localhost:3002 http://localhost:3002; script-src 'self' http://localhost:3002; style-src 'self'; object-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'"
defaultServerWriteTimeout = 30 * time.Second
defaultServerReadHeaderTimeout = 30 * time.Second
defaultLoggerSkipAssets = true
)

View File

@ -3,7 +3,11 @@
package settings
import "flag"
import (
"flag"
"git.dotya.ml/mirre-mt/pcmt/config"
)
// as per https://stackoverflow.com/a/54747682.
func isFlagPassed(name string) bool {
@ -17,3 +21,38 @@ func isFlagPassed(name string) bool {
return found
}
// sortOutFlags checks whether any flag overrides were passed and their validity.
func (s *Settings) sortOutFlags(conf *config.Config, hostFlag *string, portFlag *int, develFlag *bool) {
log.Debug("checking flag overrides")
overrideMsg := "overriding '%s' based on a flag: %+v"
if isFlagPassed("host") {
if h := *hostFlag; h != "unset" && h != conf.Host {
log.Debugf(overrideMsg, "host", h)
s.SetHost(h)
}
}
if isFlagPassed("port") {
if p := *portFlag; p > 0 && p < 65536 {
if p != conf.Port {
log.Debugf(overrideMsg, "port", p)
s.SetPort(p)
}
} else {
log.Warnf("flag-supplied port '%d' outside of bounds, ignoring", p)
}
}
if isFlagPassed("devel") {
if d := *develFlag; d != conf.DevelMode {
log.Debugf(overrideMsg, "develMode", d)
s.SetIsDevel(d)
log.Debug("making sure that CSP is set appropriately for devel mode (flag override)")
s.SetHTTPCSP(conf.HTTP.ContentSecurityPolicy)
}
}
}

View File

@ -22,6 +22,7 @@ type Settings struct {
httpGzipLevel int
httpRateLimitEnabled bool
httpRateLimit int
httpCSP string
isLive bool
isDevel bool
initCreateAdmin bool
@ -40,16 +41,11 @@ type Settings struct {
dbType string
dbIsSetUp bool
RegistrationAllowed bool
hibpAPIKey string
dehashedAPIKey string
}
const (
appName = "pcmt"
defaultPort = 3000
defaultSessionMaxAge = 86400 // seconds.
defaultHTTPDomain = "localhost"
defaultServerWriteTimeout = 30 * time.Second
defaultServerReadHeaderTimeout = 30 * time.Second
)
var log slogging.Slogger
// cleantgt is a list of ENV vars pertaining to pcmt.
var cleantgt = []string{
@ -61,6 +57,7 @@ var cleantgt = []string{
"PCMT_SESSION_ENCR_SECRET",
"PCMT_INIT_ADMIN_PASSWORD",
"PCMT_HIBP_API_KEY",
"PCMT_DEHASHED_API_KEY",
}
// New returns a new instance of the settings struct.
@ -78,13 +75,18 @@ func (s *Settings) DefaultServerReadHeaderTimeout() time.Duration {
return defaultServerReadHeaderTimeout
}
// DefaultLoggerSkipAssets returns whether the logger skips reporting asset visits.
func (s *Settings) DefaultLoggerSkipAssets() bool {
return defaultLoggerSkipAssets
}
// Consolidate reconciles whatever values are set in config and via flags and
// sets it to one place that should be regarded as a single source of truth -
// the settings struct. Order of preference for values is (from higher to
// lower) as follows: flag -> Env var -> configuration file.
func (s *Settings) Consolidate(conf *config.Config, host *string, port *int, devel *bool, version string) {
log := *slogging.Logger() // have a local copy.
log.Logger = log.Logger.With(
log = *slogging.Logger() // have a local copy.
log.Logger = log.With(
slog.Group("pcmt extra", slog.String("module", "app/settings")),
)
@ -103,7 +105,17 @@ func (s *Settings) Consolidate(conf *config.Config, host *string, port *int, dev
s.SetIsLive(conf.LiveMode)
s.SetIsDevel(conf.DevelMode)
s.SetLoggerIsJSON(conf.Logger.JSON)
s.SetSessionCookieName(conf.Session.CookieName)
if conf.HTTP.Secure {
// https://www.sjoerdlangkemper.nl/2017/02/09/cookie-prefixes/
// https://scotthelme.co.uk/tough-cookies/
// https://check-your-website.server-daten.de/prefix-cookies.html
s.SetSessionCookieName("__Host-" + conf.Session.CookieName)
} else {
s.SetSessionCookieName(conf.Session.CookieName)
}
s.SetSessionMaxAge(conf.Session.MaxAge)
s.SetSessionCookieAuthSecret(conf.Session.CookieAuthSecret)
s.SetSessionCookieEncrSecret(conf.Session.CookieEncrSecret)
@ -117,6 +129,15 @@ func (s *Settings) Consolidate(conf *config.Config, host *string, port *int, dev
s.sessionEncrIsHex = true
}
if conf.Init.CreateAdmin {
s.SetInitCreateAdmin(true)
s.SetInitAdminPassword(conf.Init.AdminPassword)
}
if conf.Registration.Allowed {
s.RegistrationAllowed = true
}
if conf.HTTP.Gzip > 0 {
s.SetHTTPGzipEnabled(true)
s.SetHTTPGzipLevel(conf.HTTP.Gzip)
@ -127,47 +148,11 @@ func (s *Settings) Consolidate(conf *config.Config, host *string, port *int, dev
s.SetHTTPRateLimit(conf.HTTP.RateLimit)
}
if conf.Init.CreateAdmin {
s.SetInitCreateAdmin(true)
s.SetInitAdminPassword(conf.Init.AdminPassword)
}
s.SetHTTPCSP(conf.HTTP.ContentSecurityPolicy)
s.SetHTTPDomain(conf.HTTP.Domain)
s.SetHTTPSecure(conf.HTTP.Secure)
log.Debug("checking flag overrides")
overrideMsg := "overriding '%s' based on a flag: %+v"
if isFlagPassed("host") {
if h := *host; h != "unset" && h != conf.Host {
log.Debugf(overrideMsg, "host", h)
s.SetHost(h)
}
}
if isFlagPassed("port") {
if p := *port; p > 0 && p < 65536 {
if p != conf.Port {
log.Debugf(overrideMsg, "port", p)
s.SetPort(p)
}
} else {
log.Warnf("flag-supplied port '%d' outside of bounds, ignoring", p)
}
}
if isFlagPassed("devel") {
if d := *devel; d != conf.DevelMode {
log.Debugf(overrideMsg, "develMode", d)
s.SetIsDevel(d)
}
}
if conf.Registration.Allowed {
s.RegistrationAllowed = true
}
s.setAPIKeys()
s.sortOutFlags(conf, host, port, devel)
s.SetVersion(version)
}
@ -183,7 +168,7 @@ func (s *Settings) Port() int {
// AppName returns the appName.
func (s *Settings) AppName() string {
return appName
return defaultAppName
}
// AppPath returns the appPath.
@ -276,6 +261,11 @@ func (s *Settings) HTTPRateLimit() int {
return s.httpRateLimit
}
// HTTPCSP returns the httpCSP.
func (s *Settings) HTTPCSP() string {
return s.httpCSP
}
// AssetsPath returns the assetsPath.
func (s *Settings) AssetsPath() string {
return s.assetsPath
@ -296,6 +286,16 @@ func (s *Settings) DbIsSetUp() bool {
return s.dbIsSetUp
}
// APIKeyHIBP returns the hibpAPIKey.
func (s *Settings) APIKeyHIBP() string {
return s.hibpAPIKey
}
// APIKeyDehashed returns the dehashedAPIKey.
func (s *Settings) APIKeyDehashed() string {
return s.dehashedAPIKey
}
// DbConnstring returns the dbConnString.
func (s *Settings) DbConnstring() string {
return s.dbConnstring
@ -364,8 +364,12 @@ func (s *Settings) SetSessionCookieEncrSecret(sessionCookieEncrSecret string) {
// SetSessionMaxAge sets sessionMaxAge.
func (s *Settings) SetSessionMaxAge(sessionMaxAge int) {
if sessionMaxAge < 1 {
log.Debug("setting cookie max age to the default")
s.sessionMaxAge = defaultSessionMaxAge
} else {
log.Debug("setting cookie max age to a config-provided value", "maxAge", sessionMaxAge)
s.sessionMaxAge = sessionMaxAge
}
}
@ -405,6 +409,20 @@ func (s *Settings) SetHTTPRateLimit(rateLimit int) {
s.httpRateLimit = rateLimit
}
// SetHTTPCSP sets the content security policy.
func (s *Settings) SetHTTPCSP(csp string) {
switch csp {
case "":
if s.isDevel {
s.httpCSP = defaultCSPDevel
} else {
s.httpCSP = defaultCSP
}
default:
s.httpCSP = csp
}
}
// SetAssetsPath sets the assetsPath.
func (s *Settings) SetAssetsPath(assetsPath string) {
s.assetsPath = assetsPath
@ -435,6 +453,16 @@ func (s *Settings) SetDbIsSetUp(is bool) {
s.dbIsSetUp = is
}
// SetAPIKeyHIBP sets the hibpAPIKey.
func (s *Settings) SetAPIKeyHIBP(k string) {
s.hibpAPIKey = k
}
// SetAPIKeyDehashed sets the dehashedAPIKey.
func (s *Settings) SetAPIKeyDehashed(k string) {
s.dehashedAPIKey = k
}
// EraseENVs attempts to clear environment vars pertaining to pcmt.
func (s *Settings) EraseENVs() error {
for _, v := range cleantgt {

5
go.mod
View File

@ -5,6 +5,7 @@ go 1.20
require (
entgo.io/ent v0.12.3
github.com/CAFxX/httpcompression v0.0.8
github.com/go-playground/validator/v10 v10.15.3
github.com/google/uuid v1.3.0
github.com/gorilla/sessions v1.2.1
github.com/labstack/echo-contrib v0.15.0
@ -29,7 +30,10 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.4.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/go-openapi/inflect v0.19.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/gorilla/context v1.1.1 // indirect
@ -38,6 +42,7 @@ require (
github.com/hashicorp/hcl/v2 v2.17.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.16.7 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect

16
go.sum
View File

@ -25,8 +25,17 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
github.com/fxamacker/cbor/v2 v2.2.1-0.20200511212021-28e39be4a84f/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
github.com/fxamacker/cbor/v2 v2.4.0 h1:ri0ArlOR+5XunOP8CRUowT0pSJOwhW098ZCUyskZD88=
github.com/fxamacker/cbor/v2 v2.4.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.15.3 h1:S+sSpunYjNPDuXkWbK+x+bA7iXiW296KG4dL3X7xUZo=
github.com/go-playground/validator/v10 v10.15.3/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
@ -71,6 +80,8 @@ github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
github.com/leanovate/gopter v0.2.5-0.20190402064358-634a59d12406 h1:+OUpk+IVvmKU0jivOVFGtOzA6U5AWFs8HE4DRzWLOUE=
github.com/leanovate/gopter v0.2.5-0.20190402064358-634a59d12406/go.mod h1:gNcbPWNEWRe4lm+bycKqxUYoH5uoVje5SkOJ3uoLer8=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/matthewhartstonge/argon2 v0.3.3 h1:38/hupgfzqO2UGxqXqmSqErE8KJvQnIxWWg7IXUqWgQ=
@ -108,9 +119,14 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=

View File

@ -13,12 +13,13 @@ import (
)
var (
ErrNoSession = errors.New("No session found, please log in")
ErrSessionExpired = errors.New("Session expired, log in again")
ErrNoSession = errors.New("No session found, please log in")
ErrSessionExpired = errors.New("Session expired, log in again")
ErrValidationFailed = errors.New("Check your input data")
)
func renderErrorPage(c echo.Context, status int, statusText, error string) error {
addHeaders(c)
defer addHeaders(c)
strStatus := strconv.Itoa(status)

View File

@ -10,20 +10,17 @@ import (
"github.com/labstack/echo/v4"
)
func Admin() echo.HandlerFunc {
return func(c echo.Context) error {
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid credentials")
}
}
const unauthorisedAccess = "Unauthorised access detected"
func Index() echo.HandlerFunc {
return func(c echo.Context) error {
addHeaders(c)
defer addHeaders(c)
if sess, _ := session.Get(setting.SessionCookieName(), c); sess != nil {
username := sess.Values["username"]
if username != nil {
return c.Redirect(http.StatusFound, "/home")
if uname, ok := sess.Values["username"].(string); ok {
if uname != "" {
return c.Redirect(http.StatusFound, "/home")
}
}
}
@ -62,7 +59,7 @@ func Healthz() echo.HandlerFunc {
}
func addHeaders(c echo.Context) {
c.Response().Writer.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
c.Response().Header().Set("Cross-Origin-Opener-Policy", "same-origin")
}
// experimental global redirect handler?

View File

@ -23,6 +23,7 @@ func getUserByID(ctx context.Context, client *ent.Client, id string) (*ent.User,
func refreshSession(sess *sessions.Session, path string, maxAge int, httpOnly, secure bool, sameSite http.SameSite) {
sess.Options = &sessions.Options{
Domain: setting.HTTPDomain(),
Path: path,
MaxAge: maxAge,
HttpOnly: httpOnly,

View File

@ -15,9 +15,7 @@ import (
func Home(client *ent.Client) echo.HandlerFunc {
return func(c echo.Context) error {
var username string
addHeaders(c)
defer addHeaders(c)
sess, _ := session.Get(setting.SessionCookieName(), c)
if sess == nil {
@ -25,25 +23,15 @@ func Home(client *ent.Client) echo.HandlerFunc {
return c.Redirect(http.StatusSeeOther, "/signin")
}
if sess.Values["foo"] != nil {
log.Info("gorilla session", "custom field test", sess.Values["foo"].(string))
}
var username string
uname := sess.Values["username"]
if uname == nil {
username, ok := sess.Values["username"].(string)
if !ok {
log.Info("session cookie found but username invalid, redirecting to signin", "endpoint", "/home")
return c.Redirect(http.StatusSeeOther, "/signin")
}
log.Info("gorilla session", "username", sess.Values["username"].(string))
username = sess.Values["username"].(string)
// example denial.
// if _, err := c.Cookie("aha"); err != nil {
// log.Printf("error: %q", err)
// return echo.NewHTTPError(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized))
// }
log.Debug("session", "username", username, "endpoint", "/home")
var u moduser.User
@ -94,15 +82,28 @@ func Home(client *ent.Client) echo.HandlerFunc {
p.Name = username
p.User = u
data := make(map[string]any)
flash := sess.Values["flash"]
if flash != nil {
data["flash"] = flash.(string)
if flsh, ok := sess.Values["flash"].(string); ok {
p.Data["flash"] = flsh
delete(sess.Values, "flash")
}
_ = sess.Save(c.Request(), c.Response())
if _, ok := sess.Values["reauthFlash"].(string); ok {
p.Data["info"] = "First time after changing the password, yay!"
// if this is the first login after the initial password change, delete
// the cookie value.
delete(sess.Values, "reauthFlash")
}
if err := sess.Save(c.Request(), c.Response()); err != nil {
log.Error("Failed to save session", "module", "handlers/home")
return renderErrorPage(
c,
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError)+" (make sure you've got cookies enabled)",
err.Error(),
)
}
err := c.Render(http.StatusOK, "home.tmpl", p)

View File

@ -12,7 +12,7 @@ import (
func Logout() echo.HandlerFunc {
return func(c echo.Context) error {
addHeaders(c)
defer addHeaders(c)
switch {
case c.Request().Method == "POST": // nolint:goconst
@ -21,14 +21,21 @@ func Logout() echo.HandlerFunc {
log.Infof("max-age before logout: %d", sess.Options.MaxAge)
sess.Options.MaxAge = -1
if username := sess.Values["username"]; username != nil {
sess.Values["username"] = ""
}
delete(sess.Values, "username")
err := sess.Save(c.Request(), c.Response())
if err != nil {
c.Logger().Error("could not delete session cookie")
return renderErrorPage(
c,
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError),
err.Error(),
)
}
c.Response().Header().Set(echo.HeaderCacheControl, "no-store")
}
return c.Redirect(http.StatusMovedPermanently, "/logout")
@ -36,8 +43,10 @@ func Logout() echo.HandlerFunc {
case c.Request().Method == "GET": // nolint:goconst
sess, _ := session.Get(setting.SessionCookieName(), c)
if sess != nil {
if username := sess.Values["username"]; username != nil {
return c.Redirect(http.StatusSeeOther, "/home")
if uname, ok := sess.Values["username"].(string); ok {
if uname != "" {
return c.Redirect(http.StatusSeeOther, "/home")
}
}
}

View File

@ -11,14 +11,43 @@ import (
func ManageAPIKeys() echo.HandlerFunc {
return func(c echo.Context) error {
addHeaders(c)
u := c.Get("sessUsr").(moduser.User)
defer addHeaders(c)
u, ok := c.Get("sessUsr").(moduser.User)
if !ok {
return renderErrorPage(
c,
http.StatusUnauthorized,
http.StatusText(http.StatusUnauthorized),
"username was nil",
)
}
if !u.IsAdmin {
c.Logger().Debug("this is a restricted endpoint", "endpoint", "/manage/api-keys", "user", u.Username, "isAdmin", u.IsAdmin)
status := http.StatusForbidden
msg := http.StatusText(status)
return renderErrorPage(
c, status, msg+": You should not be here", "Restricted endpoint",
)
}
p := newPage()
p.Title = "Manage API Keys"
p.Current = "api-keys"
p.User = u
if setting.APIKeyHIBP() != "" {
p.Data["hibpApiKey"] = setting.APIKeyHIBP()
}
if setting.APIKeyDehashed() != "" {
p.Data["dehashedApiKey"] = setting.APIKeyDehashed()
}
err := c.Render(http.StatusOK, "manage/apikeys.tmpl",
p,
)

View File

@ -18,7 +18,7 @@ import (
func ManageUsers() echo.HandlerFunc {
return func(c echo.Context) error {
addHeaders(c)
defer addHeaders(c)
u, ok := c.Get("sessUsr").(moduser.User)
if !ok {
@ -46,9 +46,9 @@ func ManageUsers() echo.HandlerFunc {
}
if !u.IsAdmin {
c.Logger().Debug("this is a restricted endpoint")
log.Warn(unauthorisedAccess, "endpoint", c.Path(), "method", c.Request().Method, "user", u.Username, "isAdmin", u.IsAdmin)
status := http.StatusUnauthorized
status := http.StatusForbidden
msg := http.StatusText(status)
return renderErrorPage(
@ -105,6 +105,7 @@ func ManageUsers() echo.HandlerFunc {
IsAdmin: u.IsAdmin,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
LastLogin: u.LastLogin,
}
allUsers = append(allUsers, usr)
@ -158,7 +159,7 @@ func ManageUsers() echo.HandlerFunc {
func CreateUser() echo.HandlerFunc { //nolint:gocognit
return func(c echo.Context) error {
addHeaders(c)
defer addHeaders(c)
u, ok := c.Get("sessUsr").(moduser.User)
if !ok {
@ -176,9 +177,9 @@ func CreateUser() echo.HandlerFunc { //nolint:gocognit
}
if !u.IsAdmin {
c.Logger().Debug("this is a restricted endpoint")
log.Warn(unauthorisedAccess, "endpoint", c.Path(), "method", c.Request().Method, "user", u.Username, "isAdmin", u.IsAdmin)
status := http.StatusUnauthorized
status := http.StatusForbidden
msg := http.StatusText(status)
return renderErrorPage(
@ -215,6 +216,7 @@ func CreateUser() echo.HandlerFunc { //nolint:gocognit
data["flash"] = msg
data["form"] = uc
p.Data = data
p.User = u
return c.Render(
http.StatusBadRequest,
@ -223,6 +225,15 @@ func CreateUser() echo.HandlerFunc { //nolint:gocognit
)
}
if err := c.Validate(uc); err != nil {
return renderErrorPage(
c,
http.StatusBadRequest,
http.StatusText(http.StatusBadRequest)+" - "+ErrValidationFailed.Error(),
err.Error(),
)
}
var msg string
usr, err := moduser.CreateUser(ctx, dbclient, uc.Email, uc.Username, uc.Password, uc.IsAdmin)
@ -259,7 +270,7 @@ func CreateUser() echo.HandlerFunc { //nolint:gocognit
func ViewUser() echo.HandlerFunc {
return func(c echo.Context) error {
addHeaders(c)
defer addHeaders(c)
u, ok := c.Get("sessUsr").(moduser.User)
if !ok {
@ -272,9 +283,9 @@ func ViewUser() echo.HandlerFunc {
}
if !u.IsAdmin {
c.Logger().Debug("this is a restricted endpoint")
log.Warn(unauthorisedAccess, "endpoint", c.Path(), "method", c.Request().Method, "user", u.Username, "isAdmin", u.IsAdmin)
status := http.StatusUnauthorized
status := http.StatusForbidden
msg := http.StatusText(status)
return renderErrorPage(
@ -308,6 +319,15 @@ func ViewUser() echo.HandlerFunc {
err := c.Bind(uid)
if err == nil { //nolint:dupl
if err := c.Validate(uid); err != nil {
return renderErrorPage(
c,
http.StatusBadRequest,
http.StatusText(http.StatusBadRequest)+" - make sure to pass in a proper UUID",
err.Error(),
)
}
usr, err := getUserByID(ctx, dbclient, uid.ID)
if err != nil {
if errors.Is(err, moduser.ErrUserNotFound) { //nolint:gocritic
@ -387,7 +407,7 @@ func ViewUser() echo.HandlerFunc {
//nolint:dupl
func EditUser() echo.HandlerFunc {
return func(c echo.Context) error {
addHeaders(c)
defer addHeaders(c)
u, ok := c.Get("sessUsr").(moduser.User)
if !ok {
@ -400,9 +420,9 @@ func EditUser() echo.HandlerFunc {
}
if !u.IsAdmin {
c.Logger().Debug("this is a restricted endpoint")
log.Warn(unauthorisedAccess, "endpoint", c.Path(), "method", c.Request().Method, "user", u.Username, "isAdmin", u.IsAdmin)
status := http.StatusUnauthorized
status := http.StatusForbidden
msg := http.StatusText(status)
return renderErrorPage(
@ -489,7 +509,7 @@ func EditUser() echo.HandlerFunc {
//nolint:dupl
func UpdateUser() echo.HandlerFunc { //nolint:gocognit
return func(c echo.Context) error {
addHeaders(c)
defer addHeaders(c)
u, ok := c.Get("sessUsr").(moduser.User)
if !ok {
@ -502,9 +522,9 @@ func UpdateUser() echo.HandlerFunc { //nolint:gocognit
}
if !u.IsAdmin {
c.Logger().Debug("this is a restricted endpoint")
log.Warn(unauthorisedAccess, "endpoint", c.Path(), "method", c.Request().Method, "user", u.Username, "isAdmin", u.IsAdmin)
status := http.StatusUnauthorized
status := http.StatusForbidden
msg := http.StatusText(status)
return renderErrorPage(
@ -635,6 +655,15 @@ func UpdateUser() echo.HandlerFunc { //nolint:gocognit
}
}
if err := c.Validate(uu); err != nil {
return renderErrorPage(
c,
http.StatusBadRequest,
http.StatusText(http.StatusBadRequest)+" - "+ErrValidationFailed.Error(),
err.Error(),
)
}
if usr.Username != uu.Username {
exists, err := moduser.UsernameExists(ctx, dbclient, uu.Username)
if err != nil {
@ -716,7 +745,7 @@ func UpdateUser() echo.HandlerFunc { //nolint:gocognit
// DeleteUserConfirmation displays user deletion confirmation confirmation page.
func DeleteUserConfirmation() echo.HandlerFunc {
return func(c echo.Context) error {
addHeaders(c)
defer addHeaders(c)
u, ok := c.Get("sessUsr").(moduser.User)
if !ok {
@ -727,7 +756,9 @@ func DeleteUserConfirmation() echo.HandlerFunc {
"username was nil",
)
} else if !u.IsAdmin {
status := http.StatusUnauthorized
log.Warn(unauthorisedAccess, "endpoint", c.Path(), "method", c.Request().Method, "user", u.Username, "isAdmin", u.IsAdmin)
status := http.StatusForbidden
msg := http.StatusText(status)
return renderErrorPage(
@ -815,7 +846,7 @@ func DeleteUserConfirmation() echo.HandlerFunc {
// DeleteUser handles user deletion POST requests.
func DeleteUser() echo.HandlerFunc {
return func(c echo.Context) error {
addHeaders(c)
defer addHeaders(c)
u, ok := c.Get("sessUsr").(moduser.User)
if !ok {
@ -826,7 +857,9 @@ func DeleteUser() echo.HandlerFunc {
"username was nil",
)
} else if !u.IsAdmin {
status := http.StatusUnauthorized
log.Warn(unauthorisedAccess, "endpoint", c.Path(), "method", c.Request().Method, "user", u.Username, "isAdmin", u.IsAdmin)
status := http.StatusForbidden
msg := http.StatusText(status)
return renderErrorPage(

View File

@ -18,27 +18,42 @@ import (
func MiddlewareSession(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if c.Request().URL.Path == "/logout" && c.Request().Method == "POST" {
log.Debug("skipping auth middleware on /logout POST", "module", "handlers/middleware")
return next(c)
}
sess, _ := session.Get(setting.SessionCookieName(), c)
var username string
if sess == nil {
return renderErrorPage(
c,
http.StatusUnauthorized,
http.StatusText(http.StatusUnauthorized)+" you need to log in again",
"you need to log in again",
)
}
// uname, ok := sess.Values["username"].(string)
uname := sess.Values["username"]
if uname != nil {
username = uname.(string)
log.Debug("Refreshing session cookie", "username", username, "module", "middleware")
if username, ok := sess.Values["username"].(string); ok {
log.Info("Refreshing session cookie",
"username", username,
"module", "middleware",
"maxAge", setting.SessionMaxAge(),
"secure", setting.HTTPSecure(),
"domain", setting.HTTPDomain(),
)
refreshSession(
sess,
"/",
setting.SessionMaxAge(),
true,
c.Request().URL.Scheme == "https", //nolint:goconst
setting.HTTPSecure(),
http.SameSiteStrictMode,
)
sess.Values["username"] = username
c.Set("sess", sess)
var u moduser.User
@ -66,7 +81,7 @@ func MiddlewareSession(next echo.HandlerFunc) echo.HandlerFunc {
c.Set("sessUsr", u)
if err := sess.Save(c.Request(), c.Response()); err != nil {
c.Logger().Error("Failed to save session", "module", "middleware")
log.Error("Failed to save session", "module", "middleware")
return renderErrorPage(
c,
@ -79,21 +94,33 @@ func MiddlewareSession(next echo.HandlerFunc) echo.HandlerFunc {
return next(c)
}
log.Warn("Could not get username from the cookie", "module", "handlers/middleware")
if !sess.IsNew {
c.Logger().Errorf("%d - %s", http.StatusUnauthorized, "you need to log in")
log.Warn("Expired session cookie (without a username) found, redirecting to sign in", "module", "handlers/middleware")
sess.Values["info"] = "Log in again, please."
if err := sess.Save(c.Request(), c.Response()); err != nil {
log.Error("Failed to save session", "module", "middleware")
return renderErrorPage(
c,
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError)+" could not save the session cookie",
err.Error(),
)
}
return c.Redirect(http.StatusTemporaryRedirect, "/signin")
}
// return renderErrorPage(
// c,
// http.StatusUnauthorized,
// http.StatusText(http.StatusUnauthorized),
// ErrNoSession.Error(),
// )
c.Logger().Warn("Could not get username from the cookie")
return next(c)
return renderErrorPage(
c,
http.StatusUnauthorized,
http.StatusText(http.StatusUnauthorized),
ErrNoSession.Error(),
)
}
}

View File

@ -4,6 +4,7 @@
package handlers
import (
"context"
"errors"
"fmt"
"net/http"
@ -16,13 +17,15 @@ import (
func GetSearchHIBP() echo.HandlerFunc {
return func(c echo.Context) error {
addHeaders(c)
defer addHeaders(c)
u, ok := c.Get("sessUsr").(moduser.User)
if !ok {
c.Logger().Warnf("Error getting user from session cookie")
} else if u.IsAdmin {
status := http.StatusUnauthorized
log.Warn(unauthorisedAccess, "endpoint", c.Path(), "method", c.Request().Method, "user", u.Username, "isAdmin", u.IsAdmin)
status := http.StatusForbidden
msg := http.StatusText(status)
return renderErrorPage(
@ -30,6 +33,25 @@ func GetSearchHIBP() echo.HandlerFunc {
)
}
if !u.IsAdmin {
ctx := context.WithValue(context.Background(), moduser.CtxKey{}, slogger)
f, err := moduser.UsrFinishedSetup(ctx, dbclient, u.ID)
if err != nil {
return renderErrorPage(
c,
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError),
err.Error(),
)
}
if !f {
log.Warn("resource access attempt without performing the initial password change", "user", u.Username, "endpoint", "/user/hibp-search")
return c.Redirect(http.StatusSeeOther, "/user/initial-password-change")
}
}
csrf := c.Get("csrf").(string)
p := newPage()
@ -70,7 +92,7 @@ func GetSearchHIBP() echo.HandlerFunc {
func SearchHIBP() echo.HandlerFunc {
return func(c echo.Context) error {
addHeaders(c)
defer addHeaders(c)
u, ok := c.Get("sessUsr").(moduser.User)
if !ok {
@ -82,7 +104,9 @@ func SearchHIBP() echo.HandlerFunc {
// )
c.Logger().Warnf("Error getting user from session cookie")
} else if u.IsAdmin {
status := http.StatusUnauthorized
log.Warn(unauthorisedAccess, "endpoint", c.Path(), "method", c.Request().Method, "user", u.Username, "isAdmin", u.IsAdmin)
status := http.StatusForbidden
msg := http.StatusText(status)
return renderErrorPage(
@ -90,6 +114,29 @@ func SearchHIBP() echo.HandlerFunc {
)
}
if !u.IsAdmin {
ctx := context.WithValue(context.Background(), moduser.CtxKey{}, slogger)
f, err := moduser.UsrFinishedSetup(ctx, dbclient, u.ID)
if err != nil {
return renderErrorPage(
c,
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError),
err.Error(),
)
}
if !f {
return renderErrorPage(
c,
http.StatusUnauthorized,
http.StatusText(http.StatusUnauthorized)+" - you need to perform your initial password change before accessing this resource",
"user never changed password",
)
}
}
csrf := c.Get("csrf").(string)
a := new(hibpSearch)
@ -102,6 +149,15 @@ func SearchHIBP() echo.HandlerFunc {
)
}
if err := c.Validate(a); err != nil {
return renderErrorPage(
c,
http.StatusBadRequest,
http.StatusText(http.StatusBadRequest)+" - "+ErrValidationFailed.Error(),
err.Error(),
)
}
breachNames, err := hibp.GetAllBreachesForAccount(a.Account)
if err != nil {
msg := "Error getting breaches for this account"

View File

@ -5,6 +5,7 @@ package handlers
import (
"context"
"errors"
"net/http"
"strconv"
@ -18,15 +19,16 @@ import (
func Signin() echo.HandlerFunc {
return func(c echo.Context) error {
addHeaders(c)
defer addHeaders(c)
if sess, _ := session.Get(setting.SessionCookieName(), c); sess != nil {
username := sess.Values["username"]
if username != nil {
return c.Redirect(http.StatusFound, "/home")
}
reauth := false
r := c.QueryParam("reauth")
if r == "true" {
reauth = true
}
sess, _ := session.Get(setting.SessionCookieName(), c)
csrf := c.Get("csrf").(string)
p := newPage()
@ -34,6 +36,47 @@ func Signin() echo.HandlerFunc {
p.Current = "signin"
p.CSRF = csrf
var uf *userSignin
if sess != nil {
if uname, ok := sess.Values["username"].(string); ok && uname != "" {
if !reauth {
return c.Redirect(http.StatusPermanentRedirect, "/home")
}
uf = &userSignin{Username: uname}
}
}
if reauth {
fl := sess.Values["reauthFlash"]
if reFl, ok := fl.(string); !ok {
p.Data["info"] = "re-login, please."
} else {
p.Data["reauthFlash"] = reFl
if uf != nil {
p.Data["form"] = uf
}
}
} else {
if i, ok := sess.Values["info"].(string); ok {
p.Data["infoFlash"] = i
delete(sess.Values, "info")
if err := sess.Save(c.Request(), c.Response()); err != nil {
log.Error("Failed to save session", "module", "middleware")
return renderErrorPage(
c,
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError)+" could not save the session cookie",
err.Error(),
)
}
}
}
return c.Render(
http.StatusOK,
"signin.tmpl",
@ -44,7 +87,7 @@ func Signin() echo.HandlerFunc {
func SigninPost(client *ent.Client) echo.HandlerFunc {
return func(c echo.Context) error {
addHeaders(c)
defer addHeaders(c)
cu := new(userSignin)
if err := c.Bind(cu); err != nil {
@ -80,10 +123,21 @@ func SigninPost(client *ent.Client) echo.HandlerFunc {
)
}
if err := c.Validate(cu); err != nil {
return renderErrorPage(
c,
http.StatusBadRequest,
http.StatusText(http.StatusBadRequest)+" - "+ErrValidationFailed.Error(),
err.Error(),
)
}
loginFailed := "Login Failed!"
ctx := context.WithValue(context.Background(), moduser.CtxKey{}, slogger)
if usr, err := moduser.QueryUser(ctx, client, username); err == nil {
usr, err := moduser.QueryUser(ctx, client, username)
if err == nil {
log.Info("attempting login", "user", &usr.ID)
if !passwd.Compare(usr.Password, password) {
@ -122,18 +176,15 @@ func SigninPost(client *ent.Client) echo.HandlerFunc {
)
}
secure := c.Request().URL.Scheme == "https" //nolint:goconst
sess, _ := session.Get(setting.SessionCookieName(), c)
if sess != nil {
sess.Options = &sessions.Options{
Path: "/",
MaxAge: setting.SessionMaxAge(),
HttpOnly: true,
Secure: secure,
Secure: setting.HTTPSecure(),
SameSite: http.SameSiteStrictMode,
}
sess.Values["foo"] = "bar"
c.Logger().Debug("saving username to the session cookie")
@ -152,6 +203,19 @@ func SigninPost(client *ent.Client) echo.HandlerFunc {
}
}
if err = moduser.UpdateUserLastLogin(ctx, client, usr.ID, usr.IsAdmin); err != nil {
if !errors.Is(err, moduser.ErrUnfinishedSetupLastLoginUpdate) {
return renderErrorPage(
c,
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError),
err.Error(),
)
}
log.Error("could not update LastLogin", "endpoint", "/home", "method", "post")
}
return c.Redirect(http.StatusMovedPermanently, "/home")
}
}

View File

@ -17,34 +17,16 @@ import (
func Signup() echo.HandlerFunc {
return func(c echo.Context) error {
addHeaders(c)
defer addHeaders(c)
sess, _ := session.Get(setting.SessionCookieName(), c)
if sess != nil {
log.Info("gorilla session", "endpoint", "signup")
username := sess.Values["username"]
if username != nil {
if uname, ok := sess.Values["username"].(string); ok && uname != "" {
return c.Redirect(http.StatusFound, "/home")
}
}
// tpl := getTmpl("signup.tmpl")
csrf := c.Get("csrf").(string)
// secure := c.Request().URL.Scheme == "https"
// cookieCSRF := &http.Cookie{
// Name: "_csrf",
// Value: csrf,
// // SameSite: http.SameSiteStrictMode,
// SameSite: http.SameSiteLaxMode,
// MaxAge: 3600,
// Secure: secure,
// HttpOnly: true,
// }
// c.SetCookie(cookieCSRF)
p := newPage()
p.Title = "Sign up"
@ -73,7 +55,7 @@ func Signup() echo.HandlerFunc {
func SignupPost(client *ent.Client) echo.HandlerFunc {
return func(c echo.Context) error {
addHeaders(c)
defer addHeaders(c)
cu := new(userSignup)
if err := c.Bind(cu); err != nil {
@ -90,6 +72,15 @@ func SignupPost(client *ent.Client) echo.HandlerFunc {
return c.Redirect(http.StatusFound, "/singup")
}
if err := c.Validate(cu); err != nil {
return renderErrorPage(
c,
http.StatusBadRequest,
http.StatusText(http.StatusBadRequest)+" - "+ErrValidationFailed.Error(),
err.Error(),
)
}
username := cu.Username
email := cu.Email
passwd := cu.Password
@ -140,17 +131,14 @@ func SignupPost(client *ent.Client) echo.HandlerFunc {
log.Infof("successfully registered user '%s'", u.Username)
log.Debug("user details", "id", u.ID, "email", u.Email, "isAdmin", u.IsAdmin)
secure := c.Request().URL.Scheme == "https" //nolint:goconst
sess, _ := session.Get(setting.SessionCookieName(), c)
sess.Options = &sessions.Options{
Path: "/",
MaxAge: setting.SessionMaxAge(),
HttpOnly: true,
Secure: secure,
Secure: setting.HTTPSecure(),
SameSite: http.SameSiteStrictMode,
}
sess.Values["foo"] = "bar"
sess.Values["username"] = username
err = sess.Save(c.Request(), c.Response())

View File

@ -4,39 +4,40 @@
package handlers
type userSignin struct {
Username string `form:"username" json:"username" validate:"required,username"`
Password string `form:"password" json:"password" validate:"required,password"`
Username string `form:"username" json:"username" validate:"required,gte=2"`
Password string `form:"password" json:"password" validate:"required,gte=20"`
}
type userSignup struct {
Username string `form:"username" json:"username" validate:"required,username"`
Email string `form:"email" json:"email" validate:"required,email"`
Password string `form:"password" json:"password" validate:"required,password"`
Username string `form:"username" json:"username" validate:"required,gte=2"`
Email string `form:"email" json:"email" validate:"required,email,gte=3"`
Password string `form:"password" json:"password" validate:"required,gte=20"`
}
// this struct is also used on update by admins, which is why the password fields are omitempty.
// when users finish setting up, admins can no longer change their passwords.
type userCreate struct {
Username string `form:"username" json:"username" validate:"required,username"`
Email string `form:"email" json:"email" validate:"required,email"`
Password string `form:"password" json:"password" validate:"omitempty,password"`
RepeatPassword string `form:"repeatPassword" json:"repeatPassword" validate:"omitempty,repeatPassword"`
IsAdmin bool `form:"isAdmin" json:"isAdmin" validate:"required,isAdmin"`
IsActive *bool `form:"isActive" json:"isActive" validate:"omitempty,isActive"`
Username string `form:"username" json:"username" validate:"required,gte=2"`
Email string `form:"email" json:"email" validate:"required,email,gte=3"`
Password string `form:"password" json:"password" validate:"omitempty,gte=20,eqfield=RepeatPassword"`
RepeatPassword string `form:"repeatPassword" json:"repeatPassword" validate:"omitempty,gte=20,eqfield=Password"`
IsAdmin bool `form:"isAdmin" json:"isAdmin" validate:"omitempty"`
IsActive *bool `form:"isActive" json:"isActive" validate:"omitempty"`
}
type userID struct {
ID string `param:"id" validate:"required,id"`
ID string `param:"id" validate:"required,uuid"`
}
type initPasswordChange struct {
NewPassword string `form:"new-password" validate:"required,new-password"`
NewPassword string `form:"new-password" validate:"required,gte=20,eqfield=RepeatNewPassword"`
RepeatNewPassword string `form:"repeat-new-password" validate:"required,gte=20,eqfield=NewPassword"`
}
type hibpSearch struct {
Account string `form:"search" validate:"required,search"`
Account string `form:"search" validate:"required,gt=2"`
}
type hibpBreachDetail struct {
BreachName string `param:"name" validate:"required,name"`
BreachName string `param:"name" validate:"required,gt=0"`
}

View File

@ -5,6 +5,7 @@ package handlers
import (
"context"
"errors"
"net/http"
moduser "git.dotya.ml/mirre-mt/pcmt/modules/user"
@ -12,9 +13,9 @@ import (
"github.com/labstack/echo/v4"
)
func InitialPasswordChange() echo.HandlerFunc {
func InitialPasswordChangePost() echo.HandlerFunc {
return func(c echo.Context) error {
addHeaders(c)
defer addHeaders(c)
u, ok := c.Get("sessUsr").(moduser.User)
if !ok {
@ -25,7 +26,9 @@ func InitialPasswordChange() echo.HandlerFunc {
"Username was nil",
)
} else if u.IsAdmin {
status := http.StatusUnauthorized
log.Warn("this is a restricted endpoint", "endpoint", "/user/initial-password-change", "user", u.Username, "isAdmin", u.IsAdmin, "route name", c.Path())
status := http.StatusForbidden
msg := http.StatusText(status)
return renderErrorPage(
@ -41,89 +44,164 @@ func InitialPasswordChange() echo.HandlerFunc {
f, err := moduser.UsrFinishedSetup(ctx, dbclient, u.ID)
switch {
case c.Request().Method == "POST":
case err != nil:
return renderErrorPage(
c,
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError),
err.Error(),
)
case f:
return c.Redirect(http.StatusSeeOther, "/user/init-password-change")
}
pw := new(initPasswordChange)
if err := c.Bind(pw); err != nil {
return renderErrorPage(
c,
http.StatusBadRequest,
http.StatusText(http.StatusBadRequest),
err.Error(),
)
}
if pw.NewPassword != pw.RepeatNewPassword {
return renderErrorPage(
c,
http.StatusBadRequest,
http.StatusText(http.StatusBadRequest)+" - the passwords were not the same",
err.Error(),
)
}
if err := c.Validate(pw); err != nil {
return renderErrorPage(
c,
http.StatusBadRequest,
http.StatusText(http.StatusBadRequest)+" - "+ErrValidationFailed.Error(),
err.Error(),
)
}
err = moduser.ChangePassFirstLogin(ctx, dbclient, u.ID, pw.NewPassword)
if err != nil {
c.Logger().Errorf("error changing initial user password: %q", err)
switch {
case err != nil:
return renderErrorPage(
c,
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError),
err.Error(),
)
case f:
return c.Redirect(http.StatusSeeOther, "/user/init-password-change")
}
pw := new(initPasswordChange)
if err := c.Bind(pw); err != nil {
case errors.Is(err, moduser.ErrPasswordEmpty):
return renderErrorPage(
c,
http.StatusBadRequest,
http.StatusText(http.StatusBadRequest),
err.Error(),
)
}
err = moduser.ChangePassFirstLogin(ctx, dbclient, u.ID, pw.NewPassword)
if err != nil {
c.Logger().Errorf("error changing initial user password: %q", err)
case errors.Is(err, moduser.ErrNewPasswordCannotEqual):
return renderErrorPage(
c,
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError),
http.StatusBadRequest,
http.StatusText(http.StatusBadRequest)+" - the new password needs to be different from the original",
err.Error(),
)
}
if sess, ok := c.Get("sess").(*sessions.Session); ok {
sess.Values["flash"] = "Successfully updated your password"
_ = sess.Save(c.Request(), c.Response())
}
return c.Redirect(http.StatusSeeOther, "/home")
case c.Request().Method == "GET":
switch {
case err != nil:
return renderErrorPage(
c,
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError),
err.Error(),
)
case f:
return c.Redirect(http.StatusSeeOther, "/home")
}
csrf := c.Get("csrf").(string)
p := newPage()
p.Title = "Initial password change"
p.Current = "init-password-change"
p.CSRF = csrf
p.User = u
err := c.Render(
http.StatusOK,
"user/init-password-change.tmpl",
p,
return renderErrorPage(
c,
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError),
err.Error(),
)
if err != nil {
c.Logger().Errorf("error: %q", err)
}
log.Info("successfully performed initial password change", "user", u.Username)
if sess, ok := c.Get("sess").(*sessions.Session); ok {
sess.Values["reauthFlash"] = "Successfully updated your password, log in again, please"
if err = sess.Save(c.Request(), c.Response()); err != nil {
return renderErrorPage(
c,
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError),
http.StatusText(http.StatusInternalServerError)+" - could not change the session cookie",
err.Error(),
)
}
}
return c.Redirect(http.StatusSeeOther, "/signin?reauth=true")
}
}
func InitialPasswordChange() echo.HandlerFunc {
return func(c echo.Context) error {
defer addHeaders(c)
u, ok := c.Get("sessUsr").(moduser.User)
if !ok {
return renderErrorPage(
c,
http.StatusUnauthorized,
http.StatusText(http.StatusUnauthorized)+", perhaps you need to log in first?",
"Username was nil",
)
} else if u.IsAdmin {
log.Warn("this is a restricted endpoint", "endpoint", "/user/initial-password-change", "user", u.Username, "isAdmin", u.IsAdmin, "route name", c.Path())
status := http.StatusForbidden
msg := http.StatusText(status)
return renderErrorPage(
c, status, msg+": You should not be here", "This endpoint is for users only",
)
}
ctx, ok := c.Get("sloggerCtx").(context.Context)
if !ok {
ctx = context.WithValue(context.Background(), moduser.CtxKey{}, slogger)
}
f, err := moduser.UsrFinishedSetup(ctx, dbclient, u.ID)
switch {
case err != nil:
return renderErrorPage(
c,
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError),
err.Error(),
)
case f:
return c.Redirect(http.StatusSeeOther, "/home")
}
csrf := c.Get("csrf").(string)
p := newPage()
p.Title = "Initial password change"
p.Current = "init-password-change"
p.CSRF = csrf
p.User = u
p.Name = u.Username
err = c.Render(
http.StatusOK,
"user/init-password-change.tmpl",
p,
)
if err != nil {
c.Logger().Errorf("error: %q", err)
return renderErrorPage(
c,
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError),
err.Error(),
)
}
return nil
}
}

View File

@ -14,18 +14,11 @@ import (
func ViewHIBP() echo.HandlerFunc {
return func(c echo.Context) error {
addHeaders(c)
defer addHeaders(c)
u, ok := c.Get("sessUsr").(moduser.User)
if !ok {
c.Logger().Warnf("Error getting user from session cookie")
} else if u.IsAdmin {
status := http.StatusUnauthorized
msg := http.StatusText(status)
return renderErrorPage(
c, status, msg+": You should not be here", "This endpoint is for users only",
)
}
h := new(hibpBreachDetail)
@ -38,6 +31,15 @@ func ViewHIBP() echo.HandlerFunc {
)
}
if err := c.Validate(h); err != nil {
return renderErrorPage(
c,
http.StatusBadRequest,
http.StatusText(http.StatusBadRequest)+" - "+ErrValidationFailed.Error(),
err.Error(),
)
}
ctx, ok := c.Get("sloggerCtx").(context.Context)
if !ok {
ctx = context.WithValue(context.Background(), hibp.CtxKey{}, slogger)

View File

@ -6,8 +6,11 @@ package user
import "errors"
var (
ErrUsersAlreadyPresent = errors.New("don't call CreateFirst when there already are another users")
ErrUserNotFound = errors.New("user not found")
ErrFailedToQueryUser = errors.New("failed to query user")
ErrBadUUID = errors.New("invalid uuid")
ErrUsersAlreadyPresent = errors.New("don't call CreateFirst when there already are another users")
ErrUserNotFound = errors.New("user not found")
ErrFailedToQueryUser = errors.New("failed to query user")
ErrBadUUID = errors.New("invalid uuid")
ErrPasswordEmpty = errors.New("password was empty")
ErrNewPasswordCannotEqual = errors.New("the new password cannot be the same as the old one")
ErrUnfinishedSetupLastLoginUpdate = errors.New("not updating last_login for users with unfinished setup")
)

View File

@ -120,6 +120,19 @@ func QueryUserByID(ctx context.Context, client *ent.Client, strID string) (*ent.
return nil, ErrBadUUID
}
return QueryUserByUUID(ctx, client, id)
}
// QueryUserByUUID returns user for the provided ID, and nil if err == nil, nil
// and err otherwise.
func QueryUserByUUID(ctx context.Context, client *ent.Client, id uuid.UUID) (*ent.User, error) {
slogger := ctx.Value(CtxKey{}).(*slogging.Slogger)
log := *slogger
log.Logger = log.Logger.With(
slog.Group("pcmt extra", slog.String("module", "modules/user")),
)
u, err := client.User.
Query().
Where(user.IDEQ(id)).
@ -171,6 +184,22 @@ func ChangePassFirstLogin(ctx context.Context, client *ent.Client, id uuid.UUID,
return nil
}
if password == "" {
return ErrPasswordEmpty
}
{
u, err := QueryUserByUUID(ctx, client, id)
if err != nil {
return err
}
equal := passwd.Compare(u.Password, password)
if equal {
return ErrNewPasswordCannotEqual
}
}
var digest []byte
digest, err = passwd.GetHash(password)
@ -230,8 +259,9 @@ func UpdateUserByAdmin(ctx context.Context, client *ent.Client, id uuid.UUID, em
var u int
switch {
// ignore updates to password when user finished setting up (if not admin).
if !isAdmin && finishedSetup {
case !isAdmin && finishedSetup:
u, err = client.User.
Update().Where(user.IDEQ(id)).
SetEmail(email).
@ -239,23 +269,44 @@ func UpdateUserByAdmin(ctx context.Context, client *ent.Client, id uuid.UUID, em
SetIsAdmin(isAdmin).
SetIsActive(active).
Save(ctx)
} else {
default:
var digest []byte
digest, err = passwd.GetHash(password)
if err != nil {
if digest, err = passwd.GetHash(password); err != nil {
log.Errorf("error hashing password: %s", err)
return errors.New("could not hash password")
}
u, err = client.User.
Update().Where(user.IDEQ(id)).
SetEmail(email).
SetUsername(username).
SetPassword(digest).
SetIsAdmin(isAdmin).
SetIsActive(active).
Save(ctx)
var origU *ent.User
if origU, err = QueryUserByUUID(ctx, client, id); err != nil {
return err
}
// handle a situation when an admin account is demoted to a
// regular-user level. reset last-login so as to force the user to go
// through the initial password change flow.
if origU.IsAdmin && !isAdmin {
u, err = client.User.
Update().Where(user.IDEQ(id)).
SetEmail(email).
SetUsername(username).
SetPassword(digest).
SetIsAdmin(isAdmin).
SetIsActive(active).
SetLastLogin(time.Unix(0, 0)).
Save(ctx)
} else {
u, err = client.User.
Update().Where(user.IDEQ(id)).
SetEmail(email).
SetUsername(username).
SetPassword(digest).
SetIsAdmin(isAdmin).
SetIsActive(active).
Save(ctx)
}
}
switch {
@ -280,6 +331,42 @@ func UpdateUserByAdmin(ctx context.Context, client *ent.Client, id uuid.UUID, em
return nil
}
// UpdateUserLastLogin serves to update the last_login param of the user. This
// parameter will not get updated for users that never finished setting up,
// return nil on success and error on err.
func UpdateUserLastLogin(ctx context.Context, client *ent.Client, id uuid.UUID, isAdmin bool) error {
slogger := ctx.Value(CtxKey{}).(*slogging.Slogger)
log := *slogger
log.Logger = log.Logger.With(
slog.Group("pcmt extra", slog.String("module", "modules/user")),
)
finishedSetup, err := UsrFinishedSetup(ctx, client, id)
if err != nil {
return err
}
if !isAdmin && !finishedSetup {
return ErrUnfinishedSetupLastLoginUpdate
}
u, err := client.User.
Update().Where(user.IDEQ(id)).
SetLastLogin(time.Now()).
Save(ctx)
switch {
case err != nil:
return fmt.Errorf("failed to update last_login for user: %w", err)
case u > 1:
return fmt.Errorf("somehow updated last_login for more than one user? count: %d", u)
}
return nil
}
// DeleteUserByID returns nil on successful deletion, err otherwise.
func DeleteUserByID(ctx context.Context, client *ent.Client, strID string) error {
slogger := ctx.Value(CtxKey{}).(*slogging.Slogger)

View File

@ -0,0 +1,36 @@
// Copyright 2023 wanderer <a_mirre at utb dot cz>
// SPDX-License-Identifier: AGPL-3.0-only
package validation
import (
"net/http"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
)
// Validator defines a validator that can be used with the echo framework.
type Validator struct {
validator *validator.Validate
}
// New provides a new instance of type Validator, initialised with the default
// validator.
func New() *Validator {
return &Validator{
validator: validator.New(
validator.WithRequiredStructEnabled(),
),
}
}
// Validate implements echo framework's Validator interface.
func (v *Validator) Validate(a any) error {
if err := v.validator.Struct(a); err != nil {
// Optionally, you could return the error to give each route more control over the status code
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
return nil
}

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
{{- if true -}}
<meta charset="utf-8">
@ -14,6 +14,8 @@
<meta property="og:title" content="{{ .AppName }}">
<meta property="og:site_name" content="{{ .AppName }}">
{{- end -}}
<meta name="description" content="Password Compromise Monitoring Tool">
<meta property="og:description" content="Password Compromise Monitoring Tool">
<meta property="og:type" content="website">
<!-- <meta name="referrer" content="no-referrer, strict-origin-when-cross-origin"> -->
<meta name="referrer" content="strict-origin-when-cross-origin">
@ -23,11 +25,7 @@
<link href="/assets/css/pcmt.css" rel="stylesheet" integrity="{{- sha384 "css/pcmt.css" -}}">
{{- if .DevelMode -}}
<!-- <meta http-equiv="content-security-policy" content="upgrade-insecure-requests; default-src 'self'; connect-src 'self' ws://localhost:3002 http://localhost:3002; script-src 'self' http://localhost:3002; style-src 'self' 'unsafe-inline';"/> -->
<meta http-equiv="content-security-policy" content="default-src 'self'; connect-src 'self' ws://localhost:3002 http://localhost:3002; script-src 'self' http://localhost:3002;"/>
<!-- inject browsersync script if running in devel mode -->
{{ template "browsersync.tmpl" }}
{{ else }}
<meta http-equiv="content-security-policy" content="upgrade-insecure-requests; default-src 'self'; connect-src 'self'; script-src 'self';"/>
{{- end -}}
</head>

View File

@ -4,19 +4,82 @@
<main class="grow">
<div class="px-2 md:px-0 place-items-center text-center">
{{ if and .Data .Data.flash }}
<h1 class="text-xl text-pink-600 dark:text-pink-500 py-2">
<h1 class="mt-8 text-xl text-pink-600 dark:text-pink-500 py-2">
{{ .Data.flash }}
</h1>
{{- end }}
{{ if and .Data .Data.info }}
<h2 class="mt-4 text-xl font-bold text-green-600 dark:text-green-500 py-2">
{{ .Data.info }}
</h2>
{{- end }}
{{ if .Name -}}
<h1 class="mt-20 text-2xl text-pink-400 font-bold">
Welcome, <code>{{.Name}}</code>!<br>
<h1 class="mt-14 text-2xl text-pink-400 font-bold">
Welcome to
<code class="p-1 rounded-sm border-l-2 border-fuchsia-200">
{{ .AppName }}</code>,
<code class="p-1 rounded-sm bg-fuchsia-200">
{{.Name}}</code>!
</h1>
{{if .User}}
{{if .User.IsAdmin}}
<div class="px-10 mx-16 md:mx-32">
<p class="mt-4 px-12 text-sm text-gray-500 dark:text-gray-400">
<span class="font-mono text-md">&#128712;</span> To query
the breach APIs, you need to create a <b>user-level</b>
account. To use it, log into it in another browser
(<em>not</em> just another browser window, as normally that
would share the session details of this user). User and
administrator-level accounts each have distinct
capabilities. Similarly to how it is only possible to
manage users using the administrator-level account,
browsing breach data is only allowed for user-level
accounts
<a href="https://git.dotya.ml/mirre-mt/masters-thesis/" rel="noopener"
target="_blank"
class="font-medium text-blue-600 hover:underline dark:text-blue-500">
&#128712; learn more about this project</a>.
</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
<span class="font-mono text-md">&#128712;</span> Add a new user
<a href="/manage/users/new"
class="font-medium text-blue-600 hover:underline dark:text-blue-500">
from here</a>, or
<a href="/manage/users"
class="font-medium text-blue-600 hover:underline dark:text-blue-500">
view a listing of all existing users</a>.
</p>
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
<span class="font-mono text-md">&#128712;</span> You can also view the configured API keys
<a href="/manage/api-keys"
class="font-medium text-blue-600 hover:underline dark:text-blue-500">
here</a>.
</p>
</div>
{{else}}
<div class="px-10 mx-16 md:px-32">
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
<span class="font-mono text-md">&#128712;</span>
Check whether your email account is a part of a breach
<a href="/user/hibp-search"
class="font-medium text-blue-600 hover:underline dark:text-blue-500">
here</a>.
</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
<span class="font-mono text-md">&#128712;</span>
Alternatively,
<a href="https://git.dotya.ml/mirre-mt/masters-thesis/"
class="font-medium text-blue-600 hover:underline dark:text-blue-500">
learn more</a> about this project.
</p>
</div>
{{end}}
{{end}}
<div class="mt-8 md:flex md:items-center md:place-items-center md:justify-between">
<form method="POST" class="w-full md:w-5xl" action="/logout">
<input type="hidden" name="csrf" value="{{- .CSRF -}}">
<button
class="w-full py-3 text-sm font-medium tracking-wide text-white capitalize transition-colors duration-300 transform bg-blue-500 rounded-lg md:w-1/4 hover:bg-blue-400 focus:outline-none focus:ring focus:ring-blue-300 focus:ring-opacity-50"
class="w-full py-3 text-sm font-medium tracking-wide text-white capitalize transition-colors duration-300 transform bg-blue-500 rounded-lg md:w-1/4 hover:bg-blue-400 focus:outline-none focus:ring focus:ring-blue-300 focus:ring-opacity-50"
type="submit">Logout</button>
</form>
</div>

View File

@ -14,27 +14,52 @@
Manage Your API keys
</h1>
<!-- hibp form -->
<div class="p-2 mx-auto mt-8 lg:max-w-5xl lg:mt-0 block">
{{- $hibpK := .Data.hibpApiKey -}}
<!-- hibp form -->
<form method="post" class="w-full lg:max-w-7xl px-2">
<input type="hidden" name="csrf" value="{{- .CSRF -}}">
<div class="items-center">
<label for="hibpApiKey" class="text-purple-500 font-bold">
Have I Been Pwned?
</label>
<input name="hibpApiKey" type="text" placeholder="HIBP API key" {{if and .Data .Data.hibpApiKey}}value="{{.Data.hibpApiKey}}"{{end}} autofocus class="py-3 px-8 mt-2 w-full valid:border text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:valid:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<p id="hibp-expl" class="hover:cursor-help mt-2 text-sm text-gray-500 dark:text-gray-400">Adds support for querying Troy Hunt's <a href="https://haveibeenpwned.com/API/v3" class="font-medium text-blue-600 hover:underline dark:text-blue-500">Have I Been Pwned? API</a>.</p>
<input name="hibpApiKey" type="text"
placeholder="HIBP API key" {{if $hibpK}}value="{{$hibpK}}"{{end}}
autofocus
disabled
class="py-3 px-8 mt-2 w-full valid:border text-gray-500 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:valid:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<p id="hibp-expl"
class="group relative hover:cursor-help mt-2 text-sm text-gray-500 dark:text-gray-400">
Adds support for querying Troy Hunt's
<a href="https://haveibeenpwned.com/API/v3"
class="font-medium text-blue-600 hover:underline dark:text-blue-500">Have I Been Pwned? API
</a>
<span
class="absolute hidden group-hover:flex -left-5 -top-2 -translate-y-full w-48 px-2 py-1 bg-gray-700 rounded-lg text-center text-white text-sm after:content-[''] after:absolute after:left-1/2 after:top-[100%] after:-translate-x-1/2 after:border-8 after:border-x-transparent after:border-b-transparent after:border-t-gray-700">
The input field reflects the value of $PCMT_HIBP_API_KEY environment variable.
</span>
</p>
</div>
<div class="mt-4 block md:flex md:items-center place-items-end space-x-0 md:space-x-2">
<button class="px-2 py-3 w-full md:w-auto text-sm font-medium tracking-wide text-white transition-colors duration-300 transform bg-blue-500 rounded-lg hover:bg-blue-400 focus:outline-none focus:ring focus:ring-blue-300 focus:ring-opacity-50">
<div class="group relative mt-4 block md:flex md:items-center place-items-end space-x-0 md:space-x-2">
<span
class="absolute hidden group-hover:flex -left-5 -top-2 -translate-y-full w-48 px-2 py-1 bg-gray-700 rounded-lg text-center text-white text-sm after:content-[''] after:absolute after:left-1/2 after:top-[100%] after:-translate-x-1/2 after:border-8 after:border-x-transparent after:border-b-transparent after:border-t-gray-700">
These buttons currently don't perform any actions. The key view is currently read-only.
</span>
<button
disabled
class="px-2 py-3 w-full md:w-auto text-sm font-medium tracking-wide text-white transition-colors duration-300 transform bg-blue-500 rounded-lg hover:bg-blue-400 focus:outline-none focus:ring focus:ring-blue-300 focus:ring-opacity-50">
Save
</button>
<button {{if and (not .Data) (not .Data.hibpApiKey)}}disabled class="cursor-not-allowed{{else}}class="transition-colors duration-300 transform hover:bg-gray-400 focus:outline-none focus:ring focus:ring-gray-300 focus:ring-opacity-50{{end}} px-2 py-3 mt-2 md:mt-0 w-full md:w-auto text-sm font-medium tracking-wide text-white bg-gray-500 rounded-lg">
<button
disabled {{if not $hibpK}}{{end}}
class="{{if not $hibpK}}cursor-not-allowed{{else}}transition-colors duration-300 transform hover:bg-gray-400 focus:outline-none focus:ring focus:ring-gray-300 focus:ring-opacity-50{{end}} px-2 py-3 mt-2 md:mt-0 w-full md:w-auto text-sm font-medium tracking-wide text-white bg-gray-500 rounded-lg">
Test key
</button>
</div>
</form>
<hr class="py-2 md:px-14 mt-4 lg:mx-22 sm:max-w-1/2">
{{- $dehashedK := .Data.dehashedApiKey -}}
<!-- dehashed.com form -->
<form method="post" class="w-full lg:max-w-7xl px-2">
<input type="hidden" name="csrf" value="{{- .CSRF -}}">
@ -42,14 +67,32 @@
<label for="dehashed-api-key" class="text-purple-500 font-bold">
DeHashed.com
</label>
<input name="dehashed-api-key" type="text" placeholder="DeHashed.com API key" {{if and .Data.form .Data.form.DehashedAPIKey}}value="{{.Data.form.DehashedAPIKey}}"{{end}} autofocus class="py-3 px-8 mt-2 w-full valid:border text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:valid:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40" aria-described-by="dehashed-expl">
<p id="dehashed-expl" class="hover:cursor-help mt-2 text-sm text-gray-500 dark:text-gray-400">Adds support for querying <a href="https://dehashed.com/" class="font-medium text-blue-600 hover:underline dark:text-blue-500">DeHashed API</a>.</p>
<input name="dehashed-api-key" type="text"
placeholder="DeHashed.com API key" {{if $dehashedK}}value="{{$dehashedK}}"{{end}}
autofocus
disabled
class="py-3 px-8 mt-2 w-full valid:border text-gray-500 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:valid:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40"
aria-described-by="dehashed-expl">
<p id="dehashed-expl" class="group relative hover:cursor-help mt-2 text-sm text-gray-500 dark:text-gray-400">Adds support for querying <a href="https://dehashed.com/" class="font-medium text-blue-600 hover:underline dark:text-blue-500">DeHashed API</a>
<span
class="absolute hidden group-hover:flex -left-5 -top-2 -translate-y-full w-54 px-2 py-1 bg-gray-700 rounded-lg text-center text-white text-sm after:content-[''] after:absolute after:left-1/2 after:top-[100%] after:-translate-x-1/2 after:border-8 after:border-x-transparent after:border-b-transparent after:border-t-gray-700">
Support for dehashed.com is coming up. The input field will reflect the value of $PCMT_DEHASHED_API_KEY environment variable.
</span>
</p>
</div>
<div class="mt-4 block md:flex md:items-center place-items-end space-x-0 md:space-x-2">
<button class="px-2 py-3 w-full md:w-auto text-sm font-medium tracking-wide text-white transition-colors duration-300 transform bg-blue-500 rounded-lg hover:bg-blue-400 focus:outline-none focus:ring focus:ring-blue-300 focus:ring-opacity-50">
<div class="group relative mt-4 block md:flex md:items-center place-items-end space-x-0 md:space-x-2">
<span
class="absolute hidden group-hover:flex -left-5 -top-2 -translate-y-full w-48 px-2 py-1 bg-gray-700 rounded-lg text-center text-white text-sm after:content-[''] after:absolute after:left-1/2 after:top-[100%] after:-translate-x-1/2 after:border-8 after:border-x-transparent after:border-b-transparent after:border-t-gray-700">
These buttons currently don't perform any actions.
</span>
<button
disabled
class="px-2 py-3 w-full md:w-auto text-sm font-medium tracking-wide text-white transition-colors duration-300 transform bg-blue-500 rounded-lg hover:bg-blue-400 focus:outline-none focus:ring focus:ring-blue-300 focus:ring-opacity-50">
Save
</button>
<button {{if and (not .Data.form) (not .Data.form.DehashedAPIKey)}}disabled class="cursor-not-allowed{{else}}class="transition-colors duration-300 transform hover:bg-gray-400 focus:outline-none focus:ring focus:ring-gray-300 focus:ring-opacity-50{{end}} px-2 py-3 mt-2 md:mt-0 w-full md:w-auto text-sm font-medium tracking-wide text-white bg-gray-500 rounded-lg">
<button
disabled {{if not $dehashedK}}{{end}}
class="{{if not $dehashedK}}cursor-not-allowed{{else}}transition-colors duration-300 transform hover:bg-gray-400 focus:outline-none focus:ring focus:ring-gray-300 focus:ring-opacity-50{{end}} px-2 py-3 mt-2 md:mt-0 w-full md:w-auto text-sm font-medium tracking-wide text-white bg-gray-500 rounded-lg">
Test key
</button>
</div>

View File

@ -28,55 +28,55 @@
<div class="p-2 mt-3 lg:mx-auto border-2 dark:border-slate-500 rounded-sm space-y-0">
<div class="flex max-h-14 place-items-baseline justify-left lg:justify-between overflow-x-auto text-ellipsis hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">ID:</span>
<h3 class="text-lg text-fuchsia-500 dark:text-fuchsia-400 px-2 overflow-x-auto text-ellipsis select-all">
<span class="text-lg text-fuchsia-500 dark:text-fuchsia-400 px-2 overflow-x-auto text-ellipsis select-all">
{{- .Data.user.ID -}}
</h3>
</span>
</div><!-- id -->
<div class="flex place-items-center justify-left lg:justify-between overflow-x-auto text-ellipsis hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Username:</span>
<h3 class="text-lg text-fuchsia-500 dark:text-fuchsia-400 px-2">
<span class="text-lg text-fuchsia-500 dark:text-fuchsia-400 px-2">
{{- .Data.user.Username -}}
</h3>
</span>
</div><!-- username -->
<div class="flex place-items-center justify-left lg:justify-between overflow-x-auto text-ellipsis hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Email:</span>
<h3 class="text-lg text-fuchsia-500 dark:text-fuchsia-400 px-2">
<span class="text-lg text-fuchsia-500 dark:text-fuchsia-400 px-2">
{{- .Data.user.Email -}}
</h3>
</span>
</div><!-- email -->
<div class="flex place-items-center justify-left lg:justify-between overflow-x-auto text-ellipsis hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Admin:</span>
<h3 class="text-lg text-fuchsia-500 dark:text-fuchsia-400 px-2">
<span class="text-lg text-fuchsia-500 dark:text-fuchsia-400 px-2">
{{- .Data.user.IsAdmin -}}
</h3>
</span>
</div><!-- isAdmin -->
<div class="flex place-items-center justify-left lg:justify-between overflow-x-auto text-ellipsis hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Active:</span>
<h3 class="text-lg text-fuchsia-500 dark:text-fuchsia-400 px-2">
<span class="text-lg text-fuchsia-500 dark:text-fuchsia-400 px-2">
{{- .Data.user.IsActive -}}
</h3>
</span>
</div><!-- isActive -->
<div class="flex place-items-center justify-left lg:justify-between overflow-x-auto text-ellipsis hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Last login:</span>
<h3 class="text-lg text-fuchsia-500 dark:text-fuchsia-400 px-2">
<span class="text-lg text-fuchsia-500 dark:text-fuchsia-400 px-2">
{{ if usrFinishedSetup .Data.user.LastLogin }}
{{- .Data.user.LastLogin -}}
{{- else -}}
never
{{- end -}}
</h3>
</span>
</div><!-- updated -->
<div class="flex place-items-center justify-left lg:justify-between overflow-x-auto text-ellipsis hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Created:</span>
<h3 class="text-lg text-fuchsia-500 dark:text-fuchsia-400 px-2">
<span class="text-lg text-fuchsia-500 dark:text-fuchsia-400 px-2">
{{- .Data.user.CreatedAt -}}
</h3>
</span>
</div><!-- created -->
<div class="flex place-items-center justify-left lg:justify-between overflow-x-auto text-ellipsis hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Updated:</span>
<h3 class="text-lg text-fuchsia-500 dark:text-fuchsia-400 px-2">
<span class="text-lg text-fuchsia-500 dark:text-fuchsia-400 px-2">
{{- .Data.user.UpdatedAt -}}
</h3>
</span>
</div><!-- updated -->
</div>
{{- end -}}

View File

@ -32,13 +32,19 @@
<span class="absolute" role="img" aria-label="person outline icon for username">
{{ template "svg-user.tmpl" }}
</span>
<input name="username" type="text" {{if and .Data.user .Data.user.Username}}value="{{.Data.user.Username}}"{{end}} placeholder="Username" required class="block w-full py-3 valid:border-green-300 required:border-blue-300 text-gray-700 bg-white border rounded-lg px-11 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<input name="username" type="text"
{{if and .Data.user .Data.user.Username}}value="{{.Data.user.Username}}" {{end}}placeholder="Username"
required
class="block w-full py-3 valid:border-green-300 required:border-blue-300 text-gray-700 bg-white border rounded-lg px-11 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
</div>
<div class="relative flex items-center mt-4">
<span class="absolute" role="img" aria-label="email icon">
{{ template "svg-email.tmpl" }}
</span>
<input name="email" type="email" {{if and .Data.user .Data.user.Email}}value="{{.Data.user.Email}}"{{end}} placeholder="Email address" required class="peer block w-full px-10 py-3 required:border-blue-300 text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<input name="email" type="email"
{{if and .Data.user .Data.user.Email}}value="{{.Data.user.Email}}" {{end}}placeholder="Email address"
required
class="peer block w-full px-10 py-3 required:border-blue-300 text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<p class="mt-2 mx-4 hidden peer-[:not(:placeholder-shown):invalid]:block text-pink-600 text-sm">
Please provide a valid email address.
</p>
@ -48,13 +54,19 @@
<span class="absolute" role="img" aria-label="password lock icon">
{{ template "svg-password.tmpl" }}
</span>
<input name="password" type="password" value="" placeholder="Password" required class="block w-full px-10 py-3 required:border-blue-300 text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<input name="password" type="password"
value="" placeholder="Password"
required
class="block w-full px-10 py-3 required:border-blue-300 text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
</div>
<div class="relative flex items-center mt-4">
<span class="absolute" role="img" aria-label="password lock icon">
{{ template "svg-password.tmpl" }}
</span>
<input name="repeatPassword" type="password" value="" placeholder="Repeat Password" required class="block w-full px-10 py-3 required:border-blue-300 text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<input name="repeatPassword" type="password"
value="" placeholder="Repeat Password"
required
class="block w-full px-10 py-3 required:border-blue-300 text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
</div>
{{- end -}}
<div class="flex pt-2 px-2 items-center justify-center gap-6">

View File

@ -22,13 +22,21 @@
<span class="absolute" role="img" aria-label="person outline icon for username">
{{ template "svg-user.tmpl" }}
</span>
<input name="username" type="text" {{if and .Data.form .Data.form.Username}}value="{{.Data.form.Username}}"{{end}} placeholder="Username" required class="block w-full py-3 valid:border-green-300 required:border-blue-300 text-gray-700 bg-white border rounded-lg px-11 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<input name="username" type="text"
placeholder="Username" {{if and .Data.form .Data.form.Username}}value="{{.Data.form.Username}}"{{end}}
autofocus
minlength="2"
required
class="block w-full py-3 valid:border required:border-blue-300 text-gray-700 bg-white border rounded-lg px-11 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
</div>
<div class="relative flex items-center mt-4">
<span class="absolute" role="img" aria-label="email icon">
{{ template "svg-email.tmpl" }}
</span>
<input name="email" type="email" {{if and .Data.form .Data.form.Email}}value="{{.Data.form.Email}}"{{end}} placeholder="Email address" required class="peer block w-full px-10 py-3 required:border-blue-300 text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<input name="email" type="email"
placeholder="Email address" {{if and .Data.form .Data.form.Email}}value="{{.Data.form.Email}}"{{end}}
required
class="peer block w-full px-10 py-3 required:border-blue-300 text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<p class="mt-2 mx-4 hidden peer-[:not(:placeholder-shown):invalid]:block text-pink-600 text-sm">
Please provide a valid email address.
</p>
@ -37,13 +45,21 @@
<span class="absolute" role="img" aria-label="password lock icon">
{{ template "svg-password.tmpl" }}
</span>
<input name="password" type="password" {{if and .Data.form .Data.form.Password}}value="{{.Data.form.Password}}"{{end}} placeholder="Password" required class="block w-full px-10 py-3 required:border-blue-300 text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<input name="password" type="password"
placeholder="Password" {{if and .Data.form .Data.form.Password}}value="{{.Data.form.Password}}"{{end}}
minlength="20"
required
class="block w-full px-10 py-3 required:border-blue-300 text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
</div>
<div class="relative flex items-center mt-4">
<span class="absolute" role="img" aria-label="password lock icon">
{{ template "svg-password.tmpl" }}
</span>
<input name="repeatPassword" type="password" {{if and .Data.form .Data.form.RepeatPassword}}value="{{.Data.form.RepeatPassword}}"{{end}} placeholder="Repeat Password" required class="block w-full px-10 py-3 required:border-blue-300 text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<input name="repeatPassword" type="password"
placeholder="Repeat Password" {{if and .Data.form .Data.form.RepeatPassword}}value="{{.Data.form.RepeatPassword}}"{{end}}
minlength="20"
required
class="block w-full px-10 py-3 required:border-blue-300 text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
</div>
<div class="flex pt-2 px-2 items-center justify-center gap-6">
<div class="mb-1 block min-h-3">
@ -65,6 +81,7 @@
value="true"
id="isActive"
name="isActive"
checked
/>
<label
class="inline-block pl-1 hover:cursor-pointer text-gray-700 dark:text-gray-300"

View File

@ -20,27 +20,52 @@
<p class="mt-2 text-md text-rose-800 dark:text-rose-500"><span class="font-medium">Error:</span> {{.Data.flash}}</p>
</div>
{{- else -}}{{end}}
{{ if and .Data .Data.reauthFlash }}
<div class="relative flex items-center mb-4">
<p class="mt-2 text-xl text-blue-500 dark:text-blue-400"><span class="italic font-medium">Success:</span> {{.Data.reauthFlash}}</p>
</div>
{{- else -}}{{end}}
{{ if and .Data .Data.infoFlash }}
<div class="relative flex items-center mb-4">
<p class="mt-2 text-xl text-blue-500 dark:text-blue-400">
{{.Data.infoFlash}}
</p>
</div>
{{- else -}}{{end}}
<!-- username field -->
<div class="relative flex items-center">
<span class="absolute" role="img" aria-label="person outline icon for username">
{{ template "svg-user.tmpl" }}
</span>
<input name="username" type="text" placeholder="Username" {{if and .Data.form .Data.form.Username}}value="{{.Data.form.Username}}"{{end}} autofocus minlength="2" required class="block w-full py-3 required:border-slate-500 dark:required:border-slate-300 required:border-3 valid:border invalid:border-pink-600 focus:invalid:border-pink-600 focus:invalid:ring-pink-300 dark:invalid:border-pink-400 dark:focus:invalid:border-pink-400 dark:focus:invalid:ring-pink-250 text-gray-700 bg-white border rounded-lg px-11 dark:bg-gray-900 dark:text-gray-300 dark:valid:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<input name="username" type="text"
placeholder="Username" {{if and .Data.form .Data.form.Username}}value="{{.Data.form.Username}}"{{end}}
autofocus
minlength="2"
required
class="block w-full py-3 required:border-slate-500 dark:required:border-slate-300 required:border-3 valid:border text-gray-700 bg-white border rounded-lg px-11 dark:bg-gray-900 dark:text-gray-300 dark:valid:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
</div>
<!-- password field -->
<div class="relative flex items-center mt-4">
<span class="absolute" role="img" aria-label="password lock icon">
{{ template "svg-password.tmpl" }}
</span>
<input name="password" type="password" placeholder="Password" {{if and .Data.form .Data.form.Password}}value="{{.Data.form.Password}}"{{else}}{{end}} minlength=12 required class="block w-full px-10 py-3 required:border-slate-500 dark:required:border-slate-300 required:border-3 valid:border invalid:border-pink-600 focus:invalid:border-pink-600 focus:invalid:ring-pink-300 dark:invalid:border-pink-400 dark:focus:invalid:border-pink-400 dark:focus:invalid:ring-pink-250 text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:valid:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<input name="password" type="password"
placeholder="Password" {{if and .Data.form .Data.form.Password}}value="{{.Data.form.Password}}"{{else}}{{end}}
minlength=12
required
class="block w-full px-10 py-3 required:border-slate-500 dark:required:border-slate-300 required:border-3 valid:border text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:valid:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
</div>
<div class="mt-8 md:flex md:items-center">
<button class="w-full px-6 py-3 text-sm font-medium tracking-wide text-white capitalize transition-colors duration-300 transform bg-blue-500 rounded-lg md:w-1/2 hover:bg-blue-400 focus:outline-none focus:ring focus:ring-blue-300 focus:ring-opacity-50">
Sign in
</button>
<a href="#" class="inline-block mt-4 text-center text-blue-500 md:mt-0 md:mx-6 lg:mx-auto hover:underline dark:text-blue-400">
Forgot your password?
<a href="#" class="group relative inline-block mt-4 text-center text-blue-500 md:mt-0 md:mx-6 lg:mx-auto hover:underline dark:text-blue-400">
Forgot your password?
<span
class="absolute hidden group-hover:flex -left-5 -top-2 -translate-y-full w-48 px-2 py-1 bg-gray-700 rounded-lg text-center text-white text-sm after:content-[''] after:absolute after:left-1/2 after:top-[100%] after:-translate-x-1/2 after:border-8 after:border-x-transparent after:border-b-transparent after:border-t-gray-700">
This is currently a no-op
</span>
</a>
</div>
<div class="mt-4 md:flex md:items-center">
@ -54,17 +79,6 @@
</form>
</div>
</div>
<div class="mt-20">
<a href="#" class="group relative inline-block text-blue-500 underline hover:text-red-500 duration-300">
Link with top tooltip
<!-- Tooltip text here -->
<span
class="absolute hidden group-hover:flex -left-5 -top-2 -translate-y-full w-48 px-2 py-1 bg-gray-700 rounded-lg text-center text-white text-sm after:content-[''] after:absolute after:left-1/2 after:top-[100%] after:-translate-x-1/2 after:border-8 after:border-x-transparent after:border-b-transparent after:border-t-gray-700">This
is some extra useful information</span>
</a>
</div>
</div>
</section>
</main>

View File

@ -18,14 +18,21 @@
<span class="absolute" role="img" aria-label="person outline icon for username">
{{ template "svg-user.tmpl" }}
</span>
<input name="username" type="text" placeholder="Username" required class="block w-full py-3 valid:border-green-300 required:border-blue-300 text-gray-700 bg-white border rounded-lg px-11 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<input name="username" type="text"
placeholder="Username"
minlength="2"
required
class="block w-full py-3 valid:border-green-300 required:border-blue-300 text-gray-700 bg-white border rounded-lg px-11 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
</div>
<div class="relative flex items-center mt-4">
<!-- <label class="block"> -->
<span class="absolute" role="img" aria-label="email icon">
{{ template "svg-email.tmpl" }}
</span>
<input name="email" type="email" placeholder="Email address" required class="peer block w-full px-10 py-3 valid:border-green-300 required:border-blue-300 text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<input name="email" type="email"
placeholder="Email address"
required
class="peer block w-full px-10 py-3 valid:border-green-300 required:border-blue-300 text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<p class="mt-2 mx-4 hidden peer-[:not(:placeholder-shown):invalid]:block text-pink-600 text-sm">
Please provide a valid email address.
</p>
@ -35,7 +42,11 @@
<span class="absolute" role="img" aria-label="password lock icon">
{{ template "svg-password.tmpl" }}
</span>
<input name="password" type="password" placeholder="Password" required class="block w-full px-10 py-3 required:border-blue-300 text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<input name="password" type="password"
placeholder="Password"
required
minlength="20"
class="block w-full px-10 py-3 required:border-blue-300 text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
</div>
<div class="mt-8 md:flex md:items-center">
<button class="w-full px-6 py-3 text-sm font-medium tracking-wide text-white capitalize transition-colors duration-300 transform bg-blue-500 rounded-lg md:w-1/2 hover:bg-blue-400 focus:outline-none focus:ring focus:ring-blue-300 focus:ring-opacity-50">

View File

@ -10,20 +10,48 @@
{{- end }}
{{ if .User -}}
<h1 class="mt-20 text-2xl text-pink-400 font-bold">
Welcome, <code>{{.Name}}</code>, since you have never changed your password here before, now is the time to do that!<br>
Welcome,
<code class="p-1 rounded-sm bg-fuchsia-200">
{{.Name}}</code>!
</h1>
<h3 class="mt-2 mb-4 text-2xl text-purple-500 dark:text-purple-300 font-bold">
Since you have never changed your password here before, now is the time to do that!
</h3>
<span class="text-lg text-gray-500 dark:text-gray-300 font-italic">
This is necessary in order to make sure that only you know the password, since the account was pre-created <em>for</em> you.
This is necessary in order to make sure that <b>only you</b> know the
password, as the account was pre-created <em>for</em> you.
</span>
<div class="block">
<div class="mt-8 md:flex md:items-center md:place-items-center md:justify-between">
<div class="mt-6 md:flex md:items-center md:place-items-center md:justify-between">
<form method="POST" class="w-full md:w-5xl" action="/user/initial-password-change">
<input type="hidden" name="csrf" value="{{- .CSRF -}}">
<div class="relative flex items-center mt-4">
<label class="group relative mt-2 font-bold text-fuchsia-500 dark:text-fuchsia-300 text-sm">
Your new password &#128712;
<span
class="absolute hidden group-hover:flex -left-5 -top-2 -translate-y-full w-48 px-2 py-1 bg-gray-700 rounded-lg text-center text-white text-sm after:content-[''] after:absolute after:left-1/2 after:top-[100%] after:-translate-x-1/2 after:border-8 after:border-x-transparent after:border-b-transparent after:border-t-gray-700">
Please, save this password into a password manager because after clicking
submit you will be logged out and will need to re-login again.
</span>
</label>
<div class="relative flex items-center">
<span class="absolute" role="img" aria-label="password lock icon">
{{ template "svg-password.tmpl" }}
</span>
<input name="password" type="new-password" placeholder="New password" {{if and .Data.form .Data.form.Password}}value="{{.Data.form.Password}}"{{else}}{{end}} minlength=20 required class="block w-full px-10 py-3 required:border-slate-500 dark:required:border-slate-300 required:border-3 valid:border text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:valid:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<input name="new-password" type="password"
placeholder="New password" {{if and .Data.form .Data.form.NewPassword}}value="{{.Data.form.NewPassword}}"{{else}}{{end}}
minlength=20
required
class="block w-full px-10 py-3 required:border-slate-500 dark:required:border-slate-300 required:border-3 valid:border text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:valid:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
</div>
<div class="relative flex items-center">
<span class="absolute" role="img" aria-label="password lock icon">
{{ template "svg-password.tmpl" }}
</span>
<input name="repeat-new-password" type="password"
placeholder="New password repeated" {{if and .Data.form .Data.form.RepeatNewPassword}}value="{{.Data.form.RepeatNewPassword}}"{{else}}{{end}}
minlength=20
required
class="block w-full px-10 py-3 required:border-slate-500 dark:required:border-slate-300 required:border-3 valid:border text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:valid:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
</div>
<button
class="w-full my-2 text-sm font-medium tracking-wide text-white capitalize transition-colors duration-300 transform bg-blue-500 rounded-lg md:w-1/4 hover:bg-blue-400 focus:outline-none focus:ring focus:ring-blue-300 focus:ring-opacity-50"