From e22ddfec40b4b94d0b6b46f8824da145676900f6 Mon Sep 17 00:00:00 2001 From: Azareal Date: Mon, 11 Mar 2019 18:47:45 +1000 Subject: [PATCH] Added support for password resets. Sha256 hashes are now stored in the SFile structures, this will come of use later. Rows should be properly closed in DefaultTopicStore.BulkGetMap. All errors should be properly reported now in DefaultTopicStore.BulkGetMap. Rows should be properly closed in DefaultUserStore.BulkGetMap. All errors should be properly reported now in DefaultUserStore.BulkGetMap. Don't have an account on the login page should now be linkified. Renamed tempra-simple to tempra_simple to avoid breaking the template transpiler. Fixed up bits and pieces of login.html on every theme. Removed an old commented code chunk from template_init.go widget_wol widgets should now get minified. bindToAlerts() should now unbind the alert items before attempting to bind to them. Tweaked the SendValidationEmail phrase. Removed a layer of indentation from DefaultAuth.ValidateMFAToken and added the ErrNoMFAToken error for when MFA isn't setup on the specified account. Email validation now uses a constant time compare to mitigate certain classes of timing attacks. Added the /accounts/password-reset/ route. Added the /accounts/password-reset/submit/ route. Added the /accounts/password-reset/token/ route. Added the /accounts/password-reset/token/submit/ route. Added the password_resets table. Added the password_reset_email_fail phrase. Added the password_reset phrase. Added the password_reset_token phrase. Added the password_reset_email_sent phrase. Added the password_reset_token_token_verified phrase. Added the login_forgot_password phrase. Added the password_reset_head phrase. Added the password_reset_username phrase. Added the password_reset_button phrase. Added the password_reset_subject phrase. Added the password_reset_body phrase. Added the password_reset_token_head phrase. Added the password_reset_token_password phrase. Added the password_reset_token_confirm_password phrase. Added the password_reset_mfa_token phrase. Added the password_reset_token_button phrase. You will need to run the updater or patcher for this commit. --- cmd/query_gen/tables.go | 12 +- common/auth.go | 38 +++-- common/email.go | 2 +- common/files.go | 24 ++- common/pages.go | 7 + common/password_reset.go | 65 ++++++++ common/template_init.go | 107 ------------ common/theme.go | 9 +- common/topic_store.go | 6 + common/user_store.go | 6 + common/widget_wol.go | 7 +- gen_router.go | 78 +++++++-- langs/english.json | 19 +++ main.go | 4 + patcher/patches.go | 13 ++ public/global.js | 1 + router_gen/routes.go | 8 +- routes/account.go | 156 +++++++++++++++++- schema/mssql/query_password_resets.sql | 7 + schema/mysql/query_password_resets.sql | 7 + schema/pgsql/query_password_resets.sql | 7 + templates/login.html | 7 +- templates/login_mfa_verify.html | 4 +- templates/password_reset.html | 18 ++ templates/password_reset_token.html | 30 ++++ themes/cosora/public/main.css | 11 +- themes/nox/overrides/login.html | 30 ++++ themes/nox/public/main.css | 10 ++ themes/shadow/overrides/login.html | 30 ++++ themes/shadow/public/main.css | 25 ++- .../DEVELOPERS.md | 0 themes/tempra_simple/overrides/login.html | 30 ++++ .../public/account.css | 0 .../public/main.css | 23 ++- .../public/media.partial.css | 0 .../public/misc.js | 0 .../public/panel.css | 0 .../public/post-avatar-bg.jpg | Bin .../public/profile.css | 0 .../public/sample.css | 0 .../tempra_simple.png} | Bin .../theme.json | 6 +- 42 files changed, 634 insertions(+), 173 deletions(-) create mode 100644 common/password_reset.go create mode 100644 schema/mssql/query_password_resets.sql create mode 100644 schema/mysql/query_password_resets.sql create mode 100644 schema/pgsql/query_password_resets.sql create mode 100644 templates/password_reset.html create mode 100644 templates/password_reset_token.html create mode 100644 themes/nox/overrides/login.html create mode 100644 themes/shadow/overrides/login.html rename themes/{tempra-simple => tempra_simple}/DEVELOPERS.md (100%) create mode 100644 themes/tempra_simple/overrides/login.html rename themes/{tempra-simple => tempra_simple}/public/account.css (100%) rename themes/{tempra-simple => tempra_simple}/public/main.css (98%) rename themes/{tempra-simple => tempra_simple}/public/media.partial.css (100%) rename themes/{tempra-simple => tempra_simple}/public/misc.js (100%) rename themes/{tempra-simple => tempra_simple}/public/panel.css (100%) rename themes/{tempra-simple => tempra_simple}/public/post-avatar-bg.jpg (100%) rename themes/{tempra-simple => tempra_simple}/public/profile.css (100%) rename themes/{tempra-simple => tempra_simple}/public/sample.css (100%) rename themes/{tempra-simple/tempra-simple.png => tempra_simple/tempra_simple.png} (100%) rename themes/{tempra-simple => tempra_simple}/theme.json (77%) diff --git a/cmd/query_gen/tables.go b/cmd/query_gen/tables.go index 4a71bf0b..f2719494 100644 --- a/cmd/query_gen/tables.go +++ b/cmd/query_gen/tables.go @@ -166,17 +166,15 @@ func createTables(adapter qgen.Adapter) error { */ // TODO: Implement password resets - /*qgen.Install.CreateTable("password_resets", "", "", + qgen.Install.CreateTable("password_resets", "", "", []tblColumn{ tblColumn{"email", "varchar", 200, false, false, ""}, - tblColumn{"uid", "int", 0, false, false, ""}, // TODO: Make this a foreign key + tblColumn{"uid", "int", 0, false, false, ""}, // TODO: Make this a foreign key tblColumn{"validated", "varchar", 200, false, false, ""}, // Token given once the one-use token is consumed, used to prevent multiple people consuming the same one-use token tblColumn{"token", "varchar", 200, false, false, ""}, - }, - []tblKey{ - tblKey{"email", "unique"}, - }, - )*/ + tblColumn{"createdAt", "createdAt", 0, false, false, ""}, + }, nil, + ) qgen.Install.CreateTable("forums", mysqlPre, mysqlCol, []tblColumn{ diff --git a/common/auth.go b/common/auth.go index 1c096ce9..3e360870 100644 --- a/common/auth.go +++ b/common/auth.go @@ -16,8 +16,9 @@ import ( "strconv" "strings" - "github.com/Azareal/Gosora/query_gen" "github.com/Azareal/Gosora/common/gauth" + "github.com/Azareal/Gosora/query_gen" + //"golang.org/x/crypto/argon2" "golang.org/x/crypto/bcrypt" ) @@ -40,6 +41,7 @@ var ErrPasswordTooLong = errors.New("The password you selected is too long") var ErrWrongPassword = errors.New("That's not the correct password.") var ErrBadMFAToken = errors.New("I'm not sure where you got that from, but that's not a valid 2FA token") var ErrWrongMFAToken = errors.New("That 2FA token isn't correct") +var ErrNoMFAToken = errors.New("This user doesn't have 2FA setup") var ErrSecretError = errors.New("There was a glitch in the system. Please contact your local administrator.") var ErrNoUserByName = errors.New("We couldn't find an account with that username.") var DefaultHashAlgo = "bcrypt" // Override this in the configuration file, not here @@ -132,23 +134,25 @@ func (auth *DefaultAuth) ValidateMFAToken(mfaToken string, uid int) error { LogError(err) return ErrSecretError } - if err != ErrNoRows { - ok, err := VerifyGAuthToken(mfaItem.Secret, mfaToken) - if err != nil { - return ErrBadMFAToken - } - if ok { - return nil - } - for i, scratch := range mfaItem.Scratch { - if subtle.ConstantTimeCompare([]byte(scratch), []byte(mfaToken)) == 1 { - err = mfaItem.BurnScratch(i) - if err != nil { - LogError(err) - return ErrSecretError - } - return nil + if err == ErrNoRows { + return ErrNoMFAToken + } + + ok, err := VerifyGAuthToken(mfaItem.Secret, mfaToken) + if err != nil { + return ErrBadMFAToken + } + if ok { + return nil + } + for i, scratch := range mfaItem.Scratch { + if subtle.ConstantTimeCompare([]byte(scratch), []byte(mfaToken)) == 1 { + err = mfaItem.BurnScratch(i) + if err != nil { + LogError(err) + return ErrSecretError } + return nil } } return ErrWrongMFAToken diff --git a/common/email.go b/common/email.go index 5304e08b..3269ebb4 100644 --- a/common/email.go +++ b/common/email.go @@ -23,7 +23,7 @@ func SendValidationEmail(username string, email string, token string) error { // TODO: Move these to the phrase system subject := "Validate Your Email - " + Site.Name - msg := "Dear " + username + ", following your registration on our forums, we ask you to validate your email, so that we can confirm that this email actually belongs to you.\n\nClick on the following link to do so. " + schema + "://" + Site.URL + "/user/edit/token/" + token + "\n\nIf you haven't created an account here, then please feel free to ignore this email.\nWe're sorry for the inconvenience this may have caused." + msg := "Dear " + username + ", to complete your registration on our forums, we need you to validate your email, so that we can confirm that this email actually belongs to you.\n\nClick on the following link to do so. " + schema + "://" + Site.URL + "/user/edit/token/" + token + "\n\nIf you haven't created an account here, then please feel free to ignore this email.\nWe're sorry for the inconvenience this may have caused." return SendEmail(email, subject, msg) } diff --git a/common/files.go b/common/files.go index 2ea51545..f192e2d9 100644 --- a/common/files.go +++ b/common/files.go @@ -3,6 +3,8 @@ package common import ( "bytes" "compress/gzip" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "io/ioutil" @@ -25,6 +27,7 @@ var staticFileMutex sync.RWMutex type SFile struct { Data []byte GzipData []byte + Sha256 []byte Pos int64 Length int64 GzipLength int64 @@ -234,7 +237,12 @@ func (list SFileList) JSTmplInit() error { return err } - list.Set("/static/"+path, SFile{data, gzipData, 0, int64(len(data)), int64(len(gzipData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)}) + // Get a checksum for CSPs and cache busting + hasher := sha256.New() + hasher.Write(data) + checksum := []byte(hex.EncodeToString(hasher.Sum(nil))) + + list.Set("/static/"+path, SFile{data, gzipData, checksum, 0, int64(len(data)), int64(len(gzipData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)}) DebugLogf("Added the '%s' static file.", path) return nil @@ -256,6 +264,11 @@ func (list SFileList) Init() error { var ext = filepath.Ext("/public/" + path) mimetype := mime.TypeByExtension(ext) + // Get a checksum for CSPs and cache busting + hasher := sha256.New() + hasher.Write(data) + checksum := []byte(hex.EncodeToString(hasher.Sum(nil))) + // Avoid double-compressing images var gzipData []byte if mimetype != "image/jpeg" && mimetype != "image/png" && mimetype != "image/gif" { @@ -274,7 +287,7 @@ func (list SFileList) Init() error { } } - list.Set("/static/"+path, SFile{data, gzipData, 0, int64(len(data)), int64(len(gzipData)), mimetype, f, f.ModTime().UTC().Format(http.TimeFormat)}) + list.Set("/static/"+path, SFile{data, gzipData, checksum, 0, int64(len(data)), int64(len(gzipData)), mimetype, f, f.ModTime().UTC().Format(http.TimeFormat)}) DebugLogf("Added the '%s' static file.", path) return nil @@ -302,7 +315,12 @@ func (list SFileList) Add(path string, prefix string) error { return err } - list.Set("/static"+path, SFile{data, gzipData, 0, int64(len(data)), int64(len(gzipData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)}) + // Get a checksum for CSPs and cache busting + hasher := sha256.New() + hasher.Write(data) + checksum := []byte(hex.EncodeToString(hasher.Sum(nil))) + + list.Set("/static"+path, SFile{data, gzipData, checksum, 0, int64(len(data)), int64(len(gzipData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)}) DebugLogf("Added the '%s' static file", path) return nil diff --git a/common/pages.go b/common/pages.go index 4af4f0d4..8303bd32 100644 --- a/common/pages.go +++ b/common/pages.go @@ -203,6 +203,13 @@ type LevelListPage struct { Levels []LevelListItem } +type ResetPage struct { + *Header + UID int + Token string + MFA bool +} + type PanelStats struct { Users int Groups int diff --git a/common/password_reset.go b/common/password_reset.go new file mode 100644 index 00000000..fafb4121 --- /dev/null +++ b/common/password_reset.go @@ -0,0 +1,65 @@ +package common + +import ( + "crypto/subtle" + "database/sql" + "errors" + + "github.com/Azareal/Gosora/query_gen" +) + +var PasswordResetter *DefaultPasswordResetter +var ErrBadResetToken = errors.New("This reset token has expired.") + +type DefaultPasswordResetter struct { + getTokens *sql.Stmt + create *sql.Stmt + delete *sql.Stmt +} + +func NewDefaultPasswordResetter(acc *qgen.Accumulator) (*DefaultPasswordResetter, error) { + return &DefaultPasswordResetter{ + getTokens: acc.Select("password_resets").Columns("token").Where("uid = ?").Prepare(), + create: acc.Insert("password_resets").Columns("email, uid, validated, token, createdAt").Fields("?,?,0,?,UTC_TIMESTAMP()").Prepare(), + delete: acc.Delete("password_resets").Where("uid =?").Prepare(), + }, acc.FirstError() +} + +func (r *DefaultPasswordResetter) Create(email string, uid int, token string) error { + _, err := r.create.Exec(email, uid, token) + return err +} + +func (r *DefaultPasswordResetter) FlushTokens(uid int) error { + _, err := r.delete.Exec(uid) + return err +} + +func (r *DefaultPasswordResetter) ValidateToken(uid int, token string) error { + rows, err := r.getTokens.Query(uid) + if err != nil { + return err + } + defer rows.Close() + + var success = false + for rows.Next() { + var rtoken string + err := rows.Scan(&rtoken) + if err != nil { + return err + } + if subtle.ConstantTimeCompare([]byte(token), []byte(rtoken)) == 1 { + success = true + } + } + err = rows.Err() + if err != nil { + return err + } + + if !success { + return ErrBadResetToken + } + return nil +} diff --git a/common/template_init.go b/common/template_init.go index 75849296..86a1e3d1 100644 --- a/common/template_init.go +++ b/common/template_init.go @@ -508,119 +508,12 @@ func compileJSTemplates(wg *sync.WaitGroup, c *tmpl.CTemplateSet, themeName stri } writeTemplate("alert", alertTmpl) /*//writeTemplate("forum", forumTmpl) - writeTemplate("topics_topic", topicListItemTmpl) writeTemplate("topic_posts", topicPostsTmpl) writeTemplate("topic_alt_posts", topicAltPostsTmpl) - writeTemplate("paginator", paginatorTmpl) - //writeTemplate("panel_themes_widgets_widget", panelWidgetsWidgetTmpl) writeTemplateList(c, &wg, dirPrefix)*/ return nil } -/*func CompileJSTemplates() error { - log.Print("Compiling the JS templates") - var config tmpl.CTemplateConfig - config.Minify = Config.MinifyTemplates - config.Debug = Dev.DebugMode - config.SuperDebug = Dev.TemplateDebug - config.SkipHandles = true - config.SkipTmplPtrMap = true - config.SkipInitBlock = false - config.PackageName = "tmpl" - - c := tmpl.NewCTemplateSet() - c.SetConfig(config) - c.SetBaseImportMap(map[string]string{ - "io": "io", - "github.com/Azareal/Gosora/common/alerts": "github.com/Azareal/Gosora/common/alerts", - }) - c.SetBuildTags("!no_templategen") - - user, user2, user3 := tmplInitUsers() - header, _, _ := tmplInitHeaders(user, user2, user3) - now := time.Now() - var varList = make(map[string]tmpl.VarItem) - - // TODO: Check what sort of path is sent exactly and use it here - alertItem := alerts.AlertItem{Avatar: "", ASID: 1, Path: "/", Message: "uh oh, something happened"} - alertTmpl, err := c.Compile("alert.html", "templates/", "alerts.AlertItem", alertItem, varList) - if err != nil { - return err - } - - c.SetBaseImportMap(map[string]string{ - "io": "io", - "github.com/Azareal/Gosora/common": "github.com/Azareal/Gosora/common", - }) - // TODO: Fix the import loop so we don't have to use this hack anymore - c.SetBuildTags("!no_templategen,tmplgentopic") - - var topicsRow = &TopicsRow{1, "topic-title", "Topic Title", "The topic content.", 1, false, false, now, now, user3.ID, 1, 1, "", "127.0.0.1", 1, 0, 1, 0, 1, "classname", "", &user2, "", 0, &user3, "General", "/forum/general.2"} - topicListItemTmpl, err := c.Compile("topics_topic.html", "templates/", "*common.TopicsRow", topicsRow, varList) - if err != nil { - return err - } - - poll := Poll{ID: 1, Type: 0, Options: map[int]string{0: "Nothing", 1: "Something"}, Results: map[int]int{0: 5, 1: 2}, QuickOptions: []PollOption{ - PollOption{0, "Nothing"}, - PollOption{1, "Something"}, - }, VoteCount: 7} - avatar, microAvatar := BuildAvatar(62, "") - miniAttach := []*MiniAttachment{&MiniAttachment{Path: "/"}} - topic := TopicUser{1, "blah", "Blah", "Hey there!", 62, false, false, now, now, 1, 1, 0, "", "127.0.0.1", 1, 0, 1, 0, "classname", poll.ID, "weird-data", BuildProfileURL("fake-user", 62), "Fake User", Config.DefaultGroup, avatar, microAvatar, 0, "", "", "", "", "", 58, false, miniAttach} - var replyList []ReplyUser - // TODO: Do we really want the UID here to be zero? - avatar, microAvatar = BuildAvatar(0, "") - replyList = append(replyList, ReplyUser{0, 0, "Yo!", "Yo!", 0, "alice", "Alice", Config.DefaultGroup, now, 0, 0, avatar, microAvatar, "", 0, "", "", "", "", 0, "127.0.0.1", false, 1, 1, "", "", miniAttach}) - - varList = make(map[string]tmpl.VarItem) - header.Title = "Topic Name" - tpage := TopicPage{header, replyList, topic, &Forum{ID: 1, Name: "Hahaha"}, poll, Paginator{[]int{1}, 1, 1}} - tpage.Forum.Link = BuildForumURL(NameToSlug(tpage.Forum.Name), tpage.Forum.ID) - topicPostsTmpl, err := c.Compile("topic_posts.html", "templates/", "common.TopicPage", tpage, varList) - if err != nil { - return err - } - topicAltPostsTmpl, err := c.Compile("topic_alt_posts.html", "templates/", "common.TopicPage", tpage, varList) - if err != nil { - return err - } - - itemsPerPage := 25 - _, page, lastPage := PageOffset(20, 1, itemsPerPage) - pageList := Paginate(20, itemsPerPage, 5) - paginatorTmpl, err := c.Compile("paginator.html", "templates/", "common.Paginator", Paginator{pageList, page, lastPage}, varList) - if err != nil { - return err - } - - var dirPrefix = "./tmpl_client/" - var wg sync.WaitGroup - var writeTemplate = func(name string, content string) { - log.Print("Writing template '" + name + "'") - if content == "" { - return //log.Fatal("No content body") - } - wg.Add(1) - go func() { - err := writeFile(dirPrefix+"template_"+name+".go", content) - if err != nil { - log.Fatal(err) - } - wg.Done() - }() - } - writeTemplate("alert", alertTmpl) - //writeTemplate("forum", forumTmpl) - writeTemplate("topics_topic", topicListItemTmpl) - writeTemplate("topic_posts", topicPostsTmpl) - writeTemplate("topic_alt_posts", topicAltPostsTmpl) - writeTemplate("paginator", paginatorTmpl) - //writeTemplate("panel_themes_widgets_widget", panelWidgetsWidgetTmpl) - writeTemplateList(c, &wg, dirPrefix) - return nil -}*/ - func getTemplateList(c *tmpl.CTemplateSet, wg *sync.WaitGroup, prefix string) string { DebugLog("in getTemplateList") pout := "\n// nolint\nfunc init() {\n" diff --git a/common/theme.go b/common/theme.go index 567156f1..2e2cecba 100644 --- a/common/theme.go +++ b/common/theme.go @@ -3,7 +3,9 @@ package common import ( "bytes" + "crypto/sha256" "database/sql" + "encoding/hex" "errors" htmpl "html/template" "io" @@ -157,7 +159,12 @@ func (theme *Theme) AddThemeStaticFiles() error { return err } - StaticFiles.Set("/static/"+theme.Name+path, SFile{data, gzipData, 0, int64(len(data)), int64(len(gzipData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)}) + // Get a checksum for CSPs and cache busting + hasher := sha256.New() + hasher.Write(data) + checksum := []byte(hex.EncodeToString(hasher.Sum(nil))) + + StaticFiles.Set("/static/"+theme.Name+path, SFile{data, gzipData, checksum, 0, int64(len(data)), int64(len(gzipData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)}) DebugLog("Added the '/" + theme.Name + path + "' static file for theme " + theme.Name + ".") return nil diff --git a/common/topic_store.go b/common/topic_store.go index 04e86fc6..bc18cbba 100644 --- a/common/topic_store.go +++ b/common/topic_store.go @@ -152,6 +152,8 @@ func (s *DefaultTopicStore) BulkGetMap(ids []int) (list map[int]*Topic, err erro if err != nil { return list, err } + defer rows.Close() + for rows.Next() { topic := &Topic{} err := rows.Scan(&topic.ID, &topic.Title, &topic.Content, &topic.CreatedBy, &topic.CreatedAt, &topic.LastReplyBy, &topic.LastReplyAt, &topic.LastReplyID, &topic.IsClosed, &topic.Sticky, &topic.ParentID, &topic.IPAddress, &topic.ViewCount, &topic.PostCount, &topic.LikeCount, &topic.AttachCount, &topic.Poll, &topic.Data) @@ -162,6 +164,10 @@ func (s *DefaultTopicStore) BulkGetMap(ids []int) (list map[int]*Topic, err erro s.cache.Set(topic) list[topic.ID] = topic } + err = rows.Err() + if err != nil { + return list, err + } // Did we miss any topics? if idCount > len(list) { diff --git a/common/user_store.go b/common/user_store.go index f634b5a3..58e53b26 100644 --- a/common/user_store.go +++ b/common/user_store.go @@ -186,6 +186,8 @@ func (mus *DefaultUserStore) BulkGetMap(ids []int) (list map[int]*User, err erro if err != nil { return list, err } + defer rows.Close() + for rows.Next() { user := &User{Loggedin: true} err := rows.Scan(&user.ID, &user.Name, &user.Group, &user.Active, &user.IsSuperAdmin, &user.Session, &user.Email, &user.RawAvatar, &user.Message, &user.URLPrefix, &user.URLName, &user.Level, &user.Score, &user.Liked, &user.LastIP, &user.TempGroup) @@ -196,6 +198,10 @@ func (mus *DefaultUserStore) BulkGetMap(ids []int) (list map[int]*User, err erro mus.cache.Set(user) list[user.ID] = user } + err = rows.Err() + if err != nil { + return list, err + } // Did we miss any users? if idCount > len(list) { diff --git a/common/widget_wol.go b/common/widget_wol.go index ca94e12a..620b8e42 100644 --- a/common/widget_wol.go +++ b/common/widget_wol.go @@ -5,6 +5,7 @@ import ( "net/http/httptest" "github.com/Azareal/Gosora/common/phrases" + min "github.com/Azareal/Gosora/common/templates" ) type wolUsers struct { @@ -53,6 +54,10 @@ func wolTick(widget *Widget) error { } buf := new(bytes.Buffer) buf.ReadFrom(w.Result().Body) - widget.TickMask.Store(buf.String()) + bs := buf.String() + if Config.MinifyTemplates { + bs = min.Minify(bs) + } + widget.TickMask.Store(bs) return nil } diff --git a/gen_router.go b/gen_router.go index fbff242c..7315c5fe 100644 --- a/gen_router.go +++ b/gen_router.go @@ -154,6 +154,10 @@ var RouteMap = map[string]interface{}{ "routes.AccountLoginMFAVerify": routes.AccountLoginMFAVerify, "routes.AccountLoginMFAVerifySubmit": routes.AccountLoginMFAVerifySubmit, "routes.AccountRegisterSubmit": routes.AccountRegisterSubmit, + "routes.AccountPasswordReset": routes.AccountPasswordReset, + "routes.AccountPasswordResetSubmit": routes.AccountPasswordResetSubmit, + "routes.AccountPasswordResetToken": routes.AccountPasswordResetToken, + "routes.AccountPasswordResetTokenSubmit": routes.AccountPasswordResetTokenSubmit, "routes.DynamicRoute": routes.DynamicRoute, "routes.UploadedFile": routes.UploadedFile, "routes.StaticFile": routes.StaticFile, @@ -295,12 +299,16 @@ var routeMapEnum = map[string]int{ "routes.AccountLoginMFAVerify": 128, "routes.AccountLoginMFAVerifySubmit": 129, "routes.AccountRegisterSubmit": 130, - "routes.DynamicRoute": 131, - "routes.UploadedFile": 132, - "routes.StaticFile": 133, - "routes.RobotsTxt": 134, - "routes.SitemapXml": 135, - "routes.BadRoute": 136, + "routes.AccountPasswordReset": 131, + "routes.AccountPasswordResetSubmit": 132, + "routes.AccountPasswordResetToken": 133, + "routes.AccountPasswordResetTokenSubmit": 134, + "routes.DynamicRoute": 135, + "routes.UploadedFile": 136, + "routes.StaticFile": 137, + "routes.RobotsTxt": 138, + "routes.SitemapXml": 139, + "routes.BadRoute": 140, } var reverseRouteMapEnum = map[int]string{ 0: "routes.Overview", @@ -434,12 +442,16 @@ var reverseRouteMapEnum = map[int]string{ 128: "routes.AccountLoginMFAVerify", 129: "routes.AccountLoginMFAVerifySubmit", 130: "routes.AccountRegisterSubmit", - 131: "routes.DynamicRoute", - 132: "routes.UploadedFile", - 133: "routes.StaticFile", - 134: "routes.RobotsTxt", - 135: "routes.SitemapXml", - 136: "routes.BadRoute", + 131: "routes.AccountPasswordReset", + 132: "routes.AccountPasswordResetSubmit", + 133: "routes.AccountPasswordResetToken", + 134: "routes.AccountPasswordResetTokenSubmit", + 135: "routes.DynamicRoute", + 136: "routes.UploadedFile", + 137: "routes.StaticFile", + 138: "routes.RobotsTxt", + 139: "routes.SitemapXml", + 140: "routes.BadRoute", } var osMapEnum = map[string]int{ "unknown": 0, @@ -738,7 +750,7 @@ func (r *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { counters.GlobalViewCounter.Bump() if prefix == "/static" { - counters.RouteViewCounter.Bump(133) + counters.RouteViewCounter.Bump(137) req.URL.Path += extraData routes.StaticFile(w, req) return @@ -2085,6 +2097,36 @@ func (r *GenRouter) routeSwitch(w http.ResponseWriter, req *http.Request, user c counters.RouteViewCounter.Bump(130) err = routes.AccountRegisterSubmit(w,req,user) + case "/accounts/password-reset/": + counters.RouteViewCounter.Bump(131) + head, err := common.UserCheck(w,req,&user) + if err != nil { + return err + } + err = routes.AccountPasswordReset(w,req,user,head) + case "/accounts/password-reset/submit/": + err = common.ParseForm(w,req,user) + if err != nil { + return err + } + + counters.RouteViewCounter.Bump(132) + err = routes.AccountPasswordResetSubmit(w,req,user) + case "/accounts/password-reset/token/": + counters.RouteViewCounter.Bump(133) + head, err := common.UserCheck(w,req,&user) + if err != nil { + return err + } + err = routes.AccountPasswordResetToken(w,req,user,head) + case "/accounts/password-reset/token/submit/": + err = common.ParseForm(w,req,user) + if err != nil { + return err + } + + counters.RouteViewCounter.Bump(134) + err = routes.AccountPasswordResetTokenSubmit(w,req,user) } /*case "/sitemaps": // TODO: Count these views req.URL.Path += extraData @@ -2099,7 +2141,7 @@ func (r *GenRouter) routeSwitch(w http.ResponseWriter, req *http.Request, user c w.Header().Del("Content-Type") w.Header().Del("Content-Encoding") } - counters.RouteViewCounter.Bump(132) + counters.RouteViewCounter.Bump(136) req.URL.Path += extraData // TODO: Find a way to propagate errors up from this? r.UploadHandler(w,req) // TODO: Count these views @@ -2109,7 +2151,7 @@ func (r *GenRouter) routeSwitch(w http.ResponseWriter, req *http.Request, user c // TODO: Add support for favicons and robots.txt files switch(extraData) { case "robots.txt": - counters.RouteViewCounter.Bump(134) + counters.RouteViewCounter.Bump(138) return routes.RobotsTxt(w,req) case "favicon.ico": req.URL.Path = "/static"+req.URL.Path+extraData @@ -2117,7 +2159,7 @@ func (r *GenRouter) routeSwitch(w http.ResponseWriter, req *http.Request, user c routes.StaticFile(w,req) return nil /*case "sitemap.xml": - counters.RouteViewCounter.Bump(135) + counters.RouteViewCounter.Bump(139) return routes.SitemapXml(w,req)*/ } return common.NotFound(w,req,nil) @@ -2128,7 +2170,7 @@ func (r *GenRouter) routeSwitch(w http.ResponseWriter, req *http.Request, user c r.RUnlock() if ok { - counters.RouteViewCounter.Bump(131) // TODO: Be more specific about *which* dynamic route it is + counters.RouteViewCounter.Bump(135) // TODO: Be more specific about *which* dynamic route it is req.URL.Path += extraData return handle(w,req,user) } @@ -2139,7 +2181,7 @@ func (r *GenRouter) routeSwitch(w http.ResponseWriter, req *http.Request, user c } else { r.DumpRequest(req,"Bad Route") } - counters.RouteViewCounter.Bump(136) + counters.RouteViewCounter.Bump(140) return common.NotFound(w,req,nil) } return err diff --git a/langs/english.json b/langs/english.json index d3b5c144..b9593f14 100644 --- a/langs/english.json +++ b/langs/english.json @@ -100,6 +100,8 @@ "register_username_too_long_prefix":"The username is too long, max: ", "register_email_fail":"We were unable to send the email for you to confirm that this email address belongs to you. You may not have access to some functionality until you do so. Please ask an administrator for assistance.", + "password_reset_email_fail":"We were unable to send a password reset email to this user.", + "alerts_no_actor":"Unable to find the actor", "alerts_no_target_user":"Unable to find the target user", "alerts_no_linked_topic":"Unable to find the linked topic", @@ -128,6 +130,8 @@ "login":"Login", "login_mfa_verify":"2FA Verify", "register":"Registration", + "password_reset":"Password Reset", + "password_reset_token":"Password Reset", "ip_search":"IP Search", "profile": "%s's Profile", "account":"My Account", @@ -305,6 +309,8 @@ "account_mail_disabled":"The mail system is currently disabled.", "account_mail_verify_success":"Your email was successfully verified.", "account_mfa_setup_success":"Two-factor authentication was successfully setup for your account.", + "password_reset_email_sent":"An email was sent to you. Please follow the steps within.", + "password_reset_token_token_verified":"Your password was successfully updated.", "panel_forum_created":"The forum was successfully created.", "panel_forum_deleted":"The forum was successfully deleted.", @@ -446,6 +452,7 @@ "login_account_password":"Password", "login_submit_button":"Login", "login_no_account":"Don't have an account?", + "login_forgot_password":"Forgot your password?", "login_mfa_verify_head":"2FA Verify", "login_mfa_verify_explanation":"Please input the code from the authenticator app below.", @@ -460,6 +467,18 @@ "register_account_anti_spam":"Ar​e y​ou a sp​am​bo​t?", "register_submit_button":"Create Account", + "password_reset_head":"Password Reset", + "password_reset_username":"Account Name", + "password_reset_button":"Send Email", + "password_reset_subject":"Reset your email", + "password_reset_body":"Dear %s, someone has requested that your password be reset. If this was you, then please click on the following link to do so, otherwise disregard this email.\n\n %s", + + "password_reset_token_head":"Password Reset", + "password_reset_token_password":"New Password", + "password_reset_token_confirm_password":"Confirm Password", + "password_reset_mfa_token":"2FA Token", + "password_reset_token_button":"Update Account", + "account_menu_head":"My Account", "account_menu_password":"Password", "account_menu_email":"Email", diff --git a/main.go b/main.go index c21db44e..e68c11db 100644 --- a/main.go +++ b/main.go @@ -165,6 +165,10 @@ func afterDBInit() (err error) { if err != nil { return errors.WithStack(err) } + common.PasswordResetter, err = common.NewDefaultPasswordResetter(acc) + if err != nil { + return errors.WithStack(err) + } // TODO: Let the admin choose other thumbnailers, maybe ones defined in plugins common.Thumbnailer = common.NewCaireThumbnailer() diff --git a/patcher/patches.go b/patcher/patches.go index 5a07029e..f2849da9 100644 --- a/patcher/patches.go +++ b/patcher/patches.go @@ -27,6 +27,7 @@ func init() { addPatch(13, patch13) addPatch(14, patch14) addPatch(15, patch15) + addPatch(16, patch16) } func patch0(scanner *bufio.Scanner) (err error) { @@ -537,3 +538,15 @@ func patch14(scanner *bufio.Scanner) error { func patch15(scanner *bufio.Scanner) error { return execStmt(qgen.Builder.SimpleInsert("settings", "name, content, type", "'google_site_verify','','html-attribute'")) } + +func patch16(scanner *bufio.Scanner) error { + return execStmt(qgen.Builder.CreateTable("password_resets", "", "", + []tblColumn{ + tblColumn{"email", "varchar", 200, false, false, ""}, + tblColumn{"uid", "int", 0, false, false, ""}, // TODO: Make this a foreign key + tblColumn{"validated", "varchar", 200, false, false, ""}, // Token given once the one-use token is consumed, used to prevent multiple people consuming the same one-use token + tblColumn{"token", "varchar", 200, false, false, ""}, + tblColumn{"createdAt", "createdAt", 0, false, false, ""}, + }, nil, + )) +} diff --git a/public/global.js b/public/global.js index 77f96896..e8474753 100644 --- a/public/global.js +++ b/public/global.js @@ -30,6 +30,7 @@ function postLink(event) { } function bindToAlerts() { + $(".alertItem.withAvatar a").unbind("click"); $(".alertItem.withAvatar a").click(function(event) { event.stopPropagation(); $.ajax({ url: "/api/?action=set&module=dismiss-alert", type: "POST", dataType: "json", error: ajaxError, data: { asid: $(this).attr("data-asid") } }); diff --git a/router_gen/routes.go b/router_gen/routes.go index 72498f36..3c13a8e8 100644 --- a/router_gen/routes.go +++ b/router_gen/routes.go @@ -65,6 +65,7 @@ func userRoutes() *RouteGroup { MemberView("routes.LevelList", "/user/levels/"), //MemberView("routes.LevelRankings", "/user/rankings/"), + //MemberView("routes.Alerts", "/user/alerts/"), ) } @@ -135,8 +136,11 @@ func accountRoutes() *RouteGroup { View("routes.AccountLoginMFAVerify", "/accounts/mfa_verify/"), AnonAction("routes.AccountLoginMFAVerifySubmit", "/accounts/mfa_verify/submit/"), // We have logic in here which filters out regular guests AnonAction("routes.AccountRegisterSubmit", "/accounts/create/submit/"), - //View("routes.AccountPasswordReset", "/accounts/password-reset/"), - //AnonAction("routes.AccountPasswordResetSubmit", "/accounts/password-reset/submit/"), + + View("routes.AccountPasswordReset", "/accounts/password-reset/"), + AnonAction("routes.AccountPasswordResetSubmit", "/accounts/password-reset/submit/"), + View("routes.AccountPasswordResetToken", "/accounts/password-reset/token/"), + AnonAction("routes.AccountPasswordResetTokenSubmit", "/accounts/password-reset/token/submit/"), ) } diff --git a/routes/account.go b/routes/account.go index a7da3215..6049cb69 100644 --- a/routes/account.go +++ b/routes/account.go @@ -5,6 +5,7 @@ import ( "crypto/subtle" "database/sql" "encoding/hex" + "html" "io" "log" "math" @@ -424,7 +425,7 @@ func AccountEditPasswordSubmit(w http.ResponseWriter, r *http.Request, user comm if newPassword != confirmPassword { return common.LocalError("The two passwords don't match.", w, r, user) } - common.SetPassword(user.ID, newPassword) + common.SetPassword(user.ID, newPassword) // TODO: Limited version of WeakPassword() // Log the user out as a safety precaution common.Auth.ForceLogout(user.ID) @@ -693,7 +694,7 @@ func AccountEditEmailTokenSubmit(w http.ResponseWriter, r *http.Request, user co return common.LocalError("You are not logged in", w, r, user) } for _, email := range emails { - if email.Token == token { + if subtle.ConstantTimeCompare([]byte(email.Token), []byte(token)) == 1 { targetEmail = email } } @@ -761,3 +762,154 @@ func LevelList(w http.ResponseWriter, r *http.Request, user common.User, header pi := common.LevelListPage{header, levels[1:]} return renderTemplate("level_list", w, r, header, pi) } + +func Alerts(w http.ResponseWriter, r *http.Request, user common.User, header *common.Header) common.RouteError { + return nil +} + +func AccountPasswordReset(w http.ResponseWriter, r *http.Request, user common.User, header *common.Header) common.RouteError { + if user.Loggedin { + return common.LocalError("You're already logged in.", w, r, user) + } + if !common.Site.EnableEmails { + return common.LocalError(phrases.GetNoticePhrase("account_mail_disabled"), w, r, user) + } + if r.FormValue("email_sent") == "1" { + header.AddNotice("password_reset_email_sent") + } + header.Title = phrases.GetTitlePhrase("password_reset") + pi := common.Page{header, tList, nil} + return renderTemplate("password_reset", w, r, header, pi) +} + +// TODO: Ratelimit this +func AccountPasswordResetSubmit(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { + if user.Loggedin { + return common.LocalError("You're already logged in.", w, r, user) + } + if !common.Site.EnableEmails { + return common.LocalError(phrases.GetNoticePhrase("account_mail_disabled"), w, r, user) + } + + username := r.PostFormValue("username") + tuser, err := common.Users.GetByName(username) + if err == sql.ErrNoRows { + // Someone trying to stir up trouble? + http.Redirect(w, r, "/accounts/password-reset/?email_sent=1", http.StatusSeeOther) + return nil + } else if err != nil { + return common.InternalError(err, w, r) + } + + token, err := common.GenerateSafeString(80) + if err != nil { + return common.InternalError(err, w, r) + } + + // TODO: Move this query somewhere else + var disc string + err = qgen.NewAcc().Select("password_resets").Columns("createdAt").DateCutoff("createdAt", 1, "hour").QueryRow().Scan(&disc) + if err != nil && err != sql.ErrNoRows { + return common.InternalError(err, w, r) + } + if err == nil { + return common.LocalError("You can only send a password reset email for a user once an hour", w, r, user) + } + + err = common.PasswordResetter.Create(tuser.Email, tuser.ID, token) + if err != nil { + return common.InternalError(err, w, r) + } + + var schema string + if common.Site.EnableSsl { + schema = "s" + } + + err = common.SendEmail(tuser.Email, phrases.GetTmplPhrase("password_reset_subject"), phrases.GetTmplPhrasef("password_reset_body", tuser.Name, "http"+schema+"://"+common.Site.URL+"/accounts/password-reset/token/?uid="+strconv.Itoa(tuser.ID)+"&token="+token)) + if err != nil { + return common.LocalError(phrases.GetErrorPhrase("password_reset_email_fail"), w, r, user) + } + + http.Redirect(w, r, "/accounts/password-reset/?email_sent=1", http.StatusSeeOther) + return nil +} + +func AccountPasswordResetToken(w http.ResponseWriter, r *http.Request, user common.User, header *common.Header) common.RouteError { + if user.Loggedin { + return common.LocalError("You're already logged in.", w, r, user) + } + // TODO: Find a way to flash this notice + /*if r.FormValue("token_verified") == "1" { + header.AddNotice("password_reset_token_token_verified") + }*/ + + token := r.FormValue("token") + uid, err := strconv.Atoi(r.FormValue("uid")) + if err != nil { + return common.LocalError("Invalid uid", w, r, user) + } + + err = common.PasswordResetter.ValidateToken(uid, token) + if err == sql.ErrNoRows || err == common.ErrBadResetToken { + return common.LocalError("This reset token has expired.", w, r, user) + } else if err != nil { + return common.InternalError(err, w, r) + } + + _, err = common.MFAstore.Get(uid) + if err != sql.ErrNoRows && err != nil { + return common.InternalError(err, w, r) + } + mfa := err != sql.ErrNoRows + + header.Title = phrases.GetTitlePhrase("password_reset_token") + return renderTemplate("password_reset_token", w, r, header, common.ResetPage{header, uid, html.EscapeString(token), mfa}) +} + +func AccountPasswordResetTokenSubmit(w http.ResponseWriter, r *http.Request, user common.User) common.RouteError { + if user.Loggedin { + return common.LocalError("You're already logged in.", w, r, user) + } + + token := r.FormValue("token") + uid, err := strconv.Atoi(r.FormValue("uid")) + if err != nil { + return common.LocalError("Invalid uid", w, r, user) + } + if !common.Users.Exists(uid) { + return common.LocalError("This reset token has expired.", w, r, user) + } + + err = common.PasswordResetter.ValidateToken(uid, token) + if err == sql.ErrNoRows || err == common.ErrBadResetToken { + return common.LocalError("This reset token has expired.", w, r, user) + } else if err != nil { + return common.InternalError(err, w, r) + } + + mfaToken := r.PostFormValue("mfa_token") + err = common.Auth.ValidateMFAToken(mfaToken, uid) + if err != nil && err != common.ErrNoMFAToken { + return common.LocalError(err.Error(), w, r, user) + } + + newPassword := r.PostFormValue("password") + confirmPassword := r.PostFormValue("confirm_password") + if newPassword != confirmPassword { + return common.LocalError("The two passwords don't match.", w, r, user) + } + common.SetPassword(uid, newPassword) // TODO: Limited version of WeakPassword() + + err = common.PasswordResetter.FlushTokens(uid) + if err != nil { + return common.InternalError(err, w, r) + } + + // Log the user out as a safety precaution + common.Auth.ForceLogout(uid) + + //http.Redirect(w, r, "/accounts/password-reset/token/?token_verified=1", http.StatusSeeOther) + http.Redirect(w, r, "/", http.StatusSeeOther) + return nil +} diff --git a/schema/mssql/query_password_resets.sql b/schema/mssql/query_password_resets.sql new file mode 100644 index 00000000..e513b77a --- /dev/null +++ b/schema/mssql/query_password_resets.sql @@ -0,0 +1,7 @@ +CREATE TABLE [password_resets] ( + [email] nvarchar (200) not null, + [uid] int not null, + [validated] nvarchar (200) not null, + [token] nvarchar (200) not null, + [createdAt] datetime not null +); \ No newline at end of file diff --git a/schema/mysql/query_password_resets.sql b/schema/mysql/query_password_resets.sql new file mode 100644 index 00000000..d7db489d --- /dev/null +++ b/schema/mysql/query_password_resets.sql @@ -0,0 +1,7 @@ +CREATE TABLE `password_resets` ( + `email` varchar(200) not null, + `uid` int not null, + `validated` varchar(200) not null, + `token` varchar(200) not null, + `createdAt` datetime not null +); \ No newline at end of file diff --git a/schema/pgsql/query_password_resets.sql b/schema/pgsql/query_password_resets.sql new file mode 100644 index 00000000..a2e2a63f --- /dev/null +++ b/schema/pgsql/query_password_resets.sql @@ -0,0 +1,7 @@ +CREATE TABLE "password_resets" ( + `email` varchar (200) not null, + `uid` int not null, + `validated` varchar (200) not null, + `token` varchar (200) not null, + `createdAt` timestamp not null +); \ No newline at end of file diff --git a/templates/login.html b/templates/login.html index f7f26c43..9571c2cb 100644 --- a/templates/login.html +++ b/templates/login.html @@ -15,7 +15,12 @@
- + +
diff --git a/templates/login_mfa_verify.html b/templates/login_mfa_verify.html index 484e2b86..a92bfc66 100644 --- a/templates/login_mfa_verify.html +++ b/templates/login_mfa_verify.html @@ -6,8 +6,8 @@
+ +
diff --git a/templates/password_reset.html b/templates/password_reset.html new file mode 100644 index 00000000..c9a7dbd5 --- /dev/null +++ b/templates/password_reset.html @@ -0,0 +1,18 @@ +{{template "header.html" . }} +
+
+

{{lang "password_reset_head"}}

+
+
+ + + + +
+
+{{template "footer.html" . }} diff --git a/templates/password_reset_token.html b/templates/password_reset_token.html new file mode 100644 index 00000000..dcdc2819 --- /dev/null +++ b/templates/password_reset_token.html @@ -0,0 +1,30 @@ +{{template "header.html" . }} +
+
+

{{lang "password_reset_token_head"}}

+
+
+
+ + + + + {{if .MFA}} + + {{end}} + +
+
+
+{{template "footer.html" . }} diff --git a/themes/cosora/public/main.css b/themes/cosora/public/main.css index b0d9b727..8e7cfb1c 100644 --- a/themes/cosora/public/main.css +++ b/themes/cosora/public/main.css @@ -1387,12 +1387,19 @@ textarea { .login_button_row { display: flex; } -.dont_have_account { +.dont_have_account, .forgot_password { color: var(--primary-link-color); font-size: 12px; - margin-left: auto; margin-top: 23px; } +.dont_have_account { + margin-left: auto; +} +.dont_have_account:after { + content: "|"; + margin-left: 5px; + margin-right: 5px; +} /* TODO: Highlight the one we're currently on? */ .pageset { diff --git a/themes/nox/overrides/login.html b/themes/nox/overrides/login.html new file mode 100644 index 00000000..b3fd3ec5 --- /dev/null +++ b/themes/nox/overrides/login.html @@ -0,0 +1,30 @@ +{{template "header.html" . }} +
+
+

{{lang "login_head"}}

+
+ +
+{{template "footer.html" . }} diff --git a/themes/nox/public/main.css b/themes/nox/public/main.css index 17dc6684..e6dd431b 100644 --- a/themes/nox/public/main.css +++ b/themes/nox/public/main.css @@ -614,6 +614,16 @@ button, .formbutton, .panel_right_button:not(.has_inner_button) { .login_mfa_token_row .formlabel { display: none; } +.fall_opts { + display: flex; +} +.dont_have_account, .forgot_password { + margin-top: 12px; + margin-bottom: -8px; +} +.forgot_password { + margin-left: auto; +} .pageset { display: flex; diff --git a/themes/shadow/overrides/login.html b/themes/shadow/overrides/login.html new file mode 100644 index 00000000..b3fd3ec5 --- /dev/null +++ b/themes/shadow/overrides/login.html @@ -0,0 +1,30 @@ +{{template "header.html" . }} +
+
+

{{lang "login_head"}}

+
+ +
+{{template "footer.html" . }} diff --git a/themes/shadow/public/main.css b/themes/shadow/public/main.css index a4853576..507cecf4 100644 --- a/themes/shadow/public/main.css +++ b/themes/shadow/public/main.css @@ -346,11 +346,32 @@ h1, h2, h3 { padding-bottom: 12px; } +.login_button_row { + display: flex; +} +.login_button_row .formitem > * { + padding-top: 5px; +} +.fall_opts { + display: flex; +} .dont_have_account { - color: #505050; + margin-left: auto; + padding-right: 0px; +} +.dont_have_account:after { + content: "|"; + padding-left: 8px; + padding-right: 8px; +} +.forgot_password { + padding-left: 0px; +} +.formitem.dont_have_account, .formitem.forgot_password { + color: #909090; font-size: 12px; font-weight: normal; - float: right; + padding-top: 11px; } textarea { diff --git a/themes/tempra-simple/DEVELOPERS.md b/themes/tempra_simple/DEVELOPERS.md similarity index 100% rename from themes/tempra-simple/DEVELOPERS.md rename to themes/tempra_simple/DEVELOPERS.md diff --git a/themes/tempra_simple/overrides/login.html b/themes/tempra_simple/overrides/login.html new file mode 100644 index 00000000..b3fd3ec5 --- /dev/null +++ b/themes/tempra_simple/overrides/login.html @@ -0,0 +1,30 @@ +{{template "header.html" . }} +
+
+

{{lang "login_head"}}

+
+ +
+{{template "footer.html" . }} diff --git a/themes/tempra-simple/public/account.css b/themes/tempra_simple/public/account.css similarity index 100% rename from themes/tempra-simple/public/account.css rename to themes/tempra_simple/public/account.css diff --git a/themes/tempra-simple/public/main.css b/themes/tempra_simple/public/main.css similarity index 98% rename from themes/tempra-simple/public/main.css rename to themes/tempra_simple/public/main.css index e9ea591b..29f39815 100644 --- a/themes/tempra-simple/public/main.css +++ b/themes/tempra_simple/public/main.css @@ -420,11 +420,26 @@ input, select { border-color: hsl(0, 0%, 80%); } -.dont_have_account { - color: #505050; - font-size: 12px; - font-weight: normal; +.fall_opts { float: right; + display: flex; +} +.dont_have_account, .forgot_password { + color: #505050; + font-size: 14px; + margin-top: 6px; + border-right: none !important; +} +.dont_have_account:after { + content: "|"; + margin-left: 5px; + margin-right: 5px; +} +.dont_have_account { + padding-right: 0px; +} +.forgot_password { + padding-left: 0px; } .ip_search_block { diff --git a/themes/tempra-simple/public/media.partial.css b/themes/tempra_simple/public/media.partial.css similarity index 100% rename from themes/tempra-simple/public/media.partial.css rename to themes/tempra_simple/public/media.partial.css diff --git a/themes/tempra-simple/public/misc.js b/themes/tempra_simple/public/misc.js similarity index 100% rename from themes/tempra-simple/public/misc.js rename to themes/tempra_simple/public/misc.js diff --git a/themes/tempra-simple/public/panel.css b/themes/tempra_simple/public/panel.css similarity index 100% rename from themes/tempra-simple/public/panel.css rename to themes/tempra_simple/public/panel.css diff --git a/themes/tempra-simple/public/post-avatar-bg.jpg b/themes/tempra_simple/public/post-avatar-bg.jpg similarity index 100% rename from themes/tempra-simple/public/post-avatar-bg.jpg rename to themes/tempra_simple/public/post-avatar-bg.jpg diff --git a/themes/tempra-simple/public/profile.css b/themes/tempra_simple/public/profile.css similarity index 100% rename from themes/tempra-simple/public/profile.css rename to themes/tempra_simple/public/profile.css diff --git a/themes/tempra-simple/public/sample.css b/themes/tempra_simple/public/sample.css similarity index 100% rename from themes/tempra-simple/public/sample.css rename to themes/tempra_simple/public/sample.css diff --git a/themes/tempra-simple/tempra-simple.png b/themes/tempra_simple/tempra_simple.png similarity index 100% rename from themes/tempra-simple/tempra-simple.png rename to themes/tempra_simple/tempra_simple.png diff --git a/themes/tempra-simple/theme.json b/themes/tempra_simple/theme.json similarity index 77% rename from themes/tempra-simple/theme.json rename to themes/tempra_simple/theme.json index 3885adad..d1b6dba4 100644 --- a/themes/tempra-simple/theme.json +++ b/themes/tempra_simple/theme.json @@ -1,9 +1,9 @@ { - "Name": "tempra-simple", + "Name": "tempra_simple", "FriendlyName": "Tempra Simple", "Version": "0.1.0-dev", "Creator": "Azareal", - "FullImage": "tempra-simple.png", + "FullImage": "tempra_simple.png", "MobileFriendly": true, "URL": "github.com/Azareal/Gosora", "BgAvatars":true, @@ -16,7 +16,7 @@ ], "Resources": [ { - "Name":"tempra-simple/misc.js", + "Name":"tempra_simple/misc.js", "Location":"global" } ]