Registration flow and email

This commit is contained in:
Asaf Gartner 2021-08-08 23:05:52 +03:00
parent 038ee7e90e
commit 660f65ba95
42 changed files with 1427 additions and 85 deletions

View File

@ -9520,3 +9520,7 @@ span.icon-rss::before {
.notice-success {
background-color: #43a52f;
background-color: var(--notice-success-color); }
.notice-failure {
background-color: #b42222;
background-color: var(--notice-failure-color); }

View File

@ -248,6 +248,7 @@ will throw an error.
--notice-lts-color: #2a681d;
--notice-lts-reqd-color: #876327;
--notice-success-color: #2a681d;
--notice-failure-color: #7a2020;
--optionbar-border-color: #333;
--tab-background: #181818;
--tab-border-color: #3f3f3f;

View File

@ -266,6 +266,7 @@ will throw an error.
--notice-lts-color: #43a52f;
--notice-lts-reqd-color: #aa7d30;
--notice-success-color: #43a52f;
--notice-failure-color: #b42222;
--optionbar-border-color: #ccc;
--tab-background: #fff;
--tab-border-color: #d8d8d8;

View File

@ -8,6 +8,12 @@ import (
"git.handmade.network/hmn/hmn/src/auth"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/email"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/perf"
"git.handmade.network/hmn/hmn/src/templates"
"git.handmade.network/hmn/hmn/src/website"
"github.com/jackc/pgx/v4"
"github.com/spf13/cobra"
@ -57,6 +63,68 @@ func init() {
fmt.Printf("Successfully updated password for '%s'\n", canonicalUsername)
},
}
website.WebsiteCommand.AddCommand(setPasswordCommand)
activateUserCommand := &cobra.Command{
Use: "activateuser [username]",
Short: "Activates a user manually",
Run: func(cmd *cobra.Command, args []string) {
if len(args) < 1 {
fmt.Printf("You must provide a username.\n\n")
cmd.Usage()
os.Exit(1)
}
username := args[0]
ctx := context.Background()
conn := db.NewConnPool(1, 1)
defer conn.Close()
res, err := conn.Exec(ctx, "UPDATE auth_user SET status = $1 WHERE LOWER(username) = LOWER($2);", models.UserStatusActive, username)
if err != nil {
panic(err)
}
if res.RowsAffected() == 0 {
fmt.Printf("User not found.\n\n")
}
fmt.Printf("User has been successfully activated.\n\n")
},
}
website.WebsiteCommand.AddCommand(activateUserCommand)
sendTestMailCommand := &cobra.Command{
Use: "sendtestmail [type] [toAddress] [toName]",
Short: "Sends a test mail",
Run: func(cmd *cobra.Command, args []string) {
templates.Init()
if len(args) < 3 {
fmt.Printf("You must provide the email type and recipient details.\n\n")
cmd.Usage()
os.Exit(1)
}
emailType := args[0]
toAddress := args[1]
toName := args[2]
p := perf.MakeNewRequestPerf("admintools", "email test", emailType)
var err error
switch emailType {
case "registration":
err = email.SendRegistrationEmail(toAddress, toName, "test_user", "test_token", p)
default:
fmt.Printf("You must provide a valid email type\n\n")
cmd.Usage()
os.Exit(1)
}
p.EndRequest()
perf.LogPerf(p, logging.Info())
if err != nil {
panic(oops.New(err, "Failed to send test email"))
}
},
}
website.WebsiteCommand.AddCommand(sendTestMailCommand)
}

View File

@ -11,8 +11,12 @@ import (
"io"
"strconv"
"strings"
"time"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4/pgxpool"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/pbkdf2"
@ -187,3 +191,47 @@ func UpdatePassword(ctx context.Context, conn *pgxpool.Pool, username string, hp
return nil
}
func DeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) (int64, error) {
tag, err := conn.Exec(ctx,
`
DELETE FROM auth_user
WHERE
status = $1 AND
(SELECT COUNT(*) as ct FROM handmade_onetimetoken AS ott WHERE ott.owner_id = auth_user.id AND ott.expires < $2) > 0;
`,
models.UserStatusInactive,
time.Now(),
)
if err != nil {
return 0, oops.New(err, "failed to delete inactive users")
}
return tag.RowsAffected(), nil
}
func PeriodicallyDeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) <-chan struct{} {
done := make(chan struct{})
go func() {
defer close(done)
t := time.NewTicker(10 * time.Second)
for {
select {
case <-t.C:
n, err := DeleteInactiveUsers(ctx, conn)
if err == nil {
if n > 0 {
logging.Info().Int64("num deleted users", n).Msg("Deleted inactive users")
}
} else {
logging.Error().Err(err).Msg("Failed to delete expired sessions")
}
case <-ctx.Done():
return
}
}
}()
return done
}

View File

@ -94,6 +94,7 @@ func NewSessionCookie(session *models.Session) *http.Cookie {
Value: session.ID,
Domain: config.Config.Auth.CookieDomain,
Path: "/",
Expires: time.Now().Add(sessionDuration),
Secure: config.Config.Auth.CookieSecure,

View File

@ -25,6 +25,14 @@ var Config = HMNConfig{
CookieDomain: ".handmade.local",
CookieSecure: false,
},
Email: EmailConfig{
ServerAddress: "smtp.example.com",
ServerPort: 587,
FromAddress: "noreply@example.com",
FromAddressPassword: "",
FromName: "Handmade Network Team",
OverrideRecipientEmail: "override@handmadedev.org", // NOTE(asaf): If this is not empty, all emails will be redirected to this address.
},
DigitalOcean: DigitalOceanConfig{
AssetsSpacesKey: "",
AssetsSpacesSecret: "",

View File

@ -23,6 +23,7 @@ type HMNConfig struct {
LogLevel zerolog.Level
Postgres PostgresConfig
Auth AuthConfig
Email EmailConfig
DigitalOcean DigitalOceanConfig
}
@ -52,6 +53,15 @@ type DigitalOceanConfig struct {
AssetsPublicUrlRoot string
}
type EmailConfig struct {
ServerAddress string
ServerPort int
FromAddress string
FromAddressPassword string
FromName string
OverrideRecipientEmail string
}
func (info PostgresConfig) DSN() string {
return fmt.Sprintf("user=%s password=%s host=%s port=%d dbname=%s", info.User, info.Password, info.Hostname, info.Port, info.DbName)
}

121
src/email/email.go Normal file
View File

@ -0,0 +1,121 @@
package email
import (
"bytes"
"fmt"
"mime"
"mime/quotedprintable"
"net/smtp"
"regexp"
"strings"
"time"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/perf"
"git.handmade.network/hmn/hmn/src/templates"
)
type RegistrationEmailData struct {
Name string
HomepageUrl string
CompleteRegistrationUrl string
}
func SendRegistrationEmail(toAddress string, toName string, username string, completionToken string, perf *perf.RequestPerf) error {
perf.StartBlock("EMAIL", "Registration email")
perf.StartBlock("EMAIL", "Rendering template")
contents, err := renderTemplate("email_registration.html", RegistrationEmailData{
Name: toName,
HomepageUrl: hmnurl.BuildHomepage(),
CompleteRegistrationUrl: hmnurl.BuildEmailConfirmation(username, completionToken),
})
if err != nil {
return err
}
perf.EndBlock()
perf.StartBlock("EMAIL", "Sending email")
err = sendMail(toAddress, toName, "[handmade.network] Registration confirmation", contents)
if err != nil {
return oops.New(err, "Failed to send email")
}
perf.EndBlock()
perf.EndBlock()
return nil
}
var EmailRegex = regexp.MustCompile(`^[^:\p{Cc} ]+@[^:\p{Cc} ]+\.[^:\p{Cc} ]+$`)
func IsEmail(address string) bool {
return EmailRegex.Match([]byte(address))
}
func renderTemplate(name string, data interface{}) (string, error) {
var buffer bytes.Buffer
template, hasTemplate := templates.Templates[name]
if !hasTemplate {
return "", oops.New(nil, "Template not found: %s", name)
}
err := template.Execute(&buffer, data)
if err != nil {
return "", oops.New(err, "Failed to render template for email")
}
contentString := string(buffer.Bytes())
contentString = strings.ReplaceAll(contentString, "\n", "\r\n")
return contentString, nil
}
func sendMail(toAddress, toName, subject, contentHtml string) error {
if config.Config.Email.OverrideRecipientEmail != "" {
toAddress = config.Config.Email.OverrideRecipientEmail
}
contents := prepMailContents(
makeHeaderAddress(toAddress, toName),
makeHeaderAddress(config.Config.Email.FromAddress, config.Config.Email.FromName),
subject,
contentHtml,
)
return smtp.SendMail(
fmt.Sprintf("%s:%d", config.Config.Email.ServerAddress, config.Config.Email.ServerPort),
smtp.PlainAuth("", config.Config.Email.FromAddress, config.Config.Email.FromAddressPassword, config.Config.Email.ServerAddress),
config.Config.Email.FromAddress,
[]string{toAddress},
contents,
)
}
func makeHeaderAddress(email, fullname string) string {
if fullname != "" {
encoded := mime.BEncoding.Encode("utf-8", fullname)
if encoded == fullname {
encoded = strings.ReplaceAll(encoded, `"`, `\"`)
encoded = fmt.Sprintf("\"%s\"", encoded)
}
return fmt.Sprintf("%s <%s>", encoded, email)
} else {
return email
}
}
func prepMailContents(toLine string, fromLine string, subject string, contentHtml string) []byte {
var builder strings.Builder
builder.WriteString(fmt.Sprintf("To: %s\r\n", toLine))
builder.WriteString(fmt.Sprintf("From: %s\r\n", fromLine))
builder.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z)))
builder.WriteString(fmt.Sprintf("Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject)))
builder.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
builder.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
builder.WriteString("\r\n")
writer := quotedprintable.NewWriter(&builder)
writer.Write([]byte(contentHtml))
writer.Close()
builder.WriteString("\r\n")
return []byte(builder.String())
}

View File

@ -28,6 +28,7 @@ func TestUrl(t *testing.T) {
func TestHomepage(t *testing.T) {
AssertRegexMatch(t, BuildHomepage(), RegexHomepage, nil)
AssertRegexMatch(t, BuildHomepageWithRegistrationSuccess(), RegexHomepage, nil)
AssertRegexMatch(t, BuildProjectHomepage("hero"), RegexHomepage, nil)
AssertSubdomain(t, BuildProjectHomepage("hero"), "hero")
}
@ -70,6 +71,14 @@ func TestRegister(t *testing.T) {
AssertRegexMatch(t, BuildRegister(), RegexRegister, nil)
}
func TestRegistrationSuccess(t *testing.T) {
AssertRegexMatch(t, BuildRegistrationSuccess(), RegexRegistrationSuccess, nil)
}
func TestEmailConfirmation(t *testing.T) {
AssertRegexMatch(t, BuildEmailConfirmation("mruser", "test_token"), RegexEmailConfirmation, map[string]string{"username": "mruser", "token": "test_token"})
}
func TestStaticPages(t *testing.T) {
AssertRegexMatch(t, BuildManifesto(), RegexManifesto, nil)
AssertRegexMatch(t, BuildAbout(), RegexAbout, nil)
@ -204,12 +213,12 @@ func TestBlog(t *testing.T) {
}
func TestBlogThread(t *testing.T) {
AssertRegexMatch(t, BuildBlogThread("", 1, "", 1), RegexBlogThread, map[string]string{"threadid": "1"})
AssertRegexMatch(t, BuildBlogThread("", 1, "", 2), RegexBlogThread, map[string]string{"threadid": "1", "page": "2"})
AssertRegexMatch(t, BuildBlogThread("", 1, "title/bla/http://", 2), RegexBlogThread, map[string]string{"threadid": "1", "page": "2"})
AssertRegexMatch(t, BuildBlogThreadWithPostHash("", 1, "title/bla/http://", 2, 123), RegexBlogThread, map[string]string{"threadid": "1", "page": "2"})
AssertRegexNoMatch(t, BuildBlogThread("", 1, "", 2), RegexBlog)
AssertSubdomain(t, BuildBlogThread("hero", 1, "", 1), "hero")
AssertRegexMatch(t, BuildBlogThread("", 1, ""), RegexBlogThread, map[string]string{"threadid": "1"})
AssertRegexMatch(t, BuildBlogThread("", 1, ""), RegexBlogThread, map[string]string{"threadid": "1"})
AssertRegexMatch(t, BuildBlogThread("", 1, "title/bla/http://"), RegexBlogThread, map[string]string{"threadid": "1"})
AssertRegexMatch(t, BuildBlogThreadWithPostHash("", 1, "title/bla/http://", 123), RegexBlogThread, map[string]string{"threadid": "1"})
AssertRegexNoMatch(t, BuildBlogThread("", 1, ""), RegexBlog)
AssertSubdomain(t, BuildBlogThread("hero", 1, ""), "hero")
}
func TestBlogPost(t *testing.T) {
@ -236,12 +245,6 @@ func TestBlogPostReply(t *testing.T) {
AssertSubdomain(t, BuildBlogPostReply("hero", 1, 2), "hero")
}
func TestBlogPostQuote(t *testing.T) {
AssertRegexMatch(t, BuildBlogPostQuote("", 1, 2), RegexBlogPostQuote, map[string]string{"threadid": "1", "postid": "2"})
AssertRegexNoMatch(t, BuildBlogPostQuote("", 1, 2), RegexBlogPost)
AssertSubdomain(t, BuildBlogPostQuote("hero", 1, 2), "hero")
}
func TestLibrary(t *testing.T) {
AssertRegexMatch(t, BuildLibrary(""), RegexLibrary, nil)
AssertSubdomain(t, BuildLibrary("hero"), "hero")

View File

@ -16,12 +16,17 @@ Any function in this package whose name starts with Build is required to be cove
This helps ensure that we don't generate URLs that can't be routed.
*/
var RegexOldHome = regexp.MustCompile("^/home$")
var RegexHomepage = regexp.MustCompile("^/$")
func BuildHomepage() string {
return Url("/", nil)
}
func BuildHomepageWithRegistrationSuccess() string {
return Url("/", []Q{{Name: "registered", Value: "true"}})
}
func BuildProjectHomepage(projectSlug string) string {
defer CatchPanic()
return ProjectUrl("/", nil, projectSlug)
@ -57,11 +62,11 @@ func BuildLoginAction(redirectTo string) string {
return Url("/login", []Q{{Name: "redirect", Value: redirectTo}})
}
var RegexLoginPage = regexp.MustCompile("^/_login$")
var RegexLoginPage = regexp.MustCompile("^/login$")
func BuildLoginPage(redirectTo string) string {
defer CatchPanic()
return Url("/_login", []Q{{Name: "redirect", Value: redirectTo}})
return Url("/login", []Q{{Name: "redirect", Value: redirectTo}})
}
var RegexLogoutAction = regexp.MustCompile("^/logout$")
@ -74,11 +79,34 @@ func BuildLogoutAction(redir string) string {
return Url("/logout", []Q{{"redirect", redir}})
}
var RegexRegister = regexp.MustCompile("^/_register$")
var RegexRegister = regexp.MustCompile("^/register$")
func BuildRegister() string {
defer CatchPanic()
return Url("/_register", nil)
return Url("/register", nil)
}
var RegexRegistrationSuccess = regexp.MustCompile("^/registered_successfully$")
func BuildRegistrationSuccess() string {
defer CatchPanic()
return Url("/registered_successfully", nil)
}
// TODO(asaf): Delete the old version a bit after launch
var RegexOldEmailConfirmation = regexp.MustCompile(`^/_register/confirm/(?P<username>[\w\ \.\,\-@\+\_]+)/(?P<hash>[\d\w]+)/(?P<nonce>.+)[\/]?$`)
var RegexEmailConfirmation = regexp.MustCompile("^/email_confirmation/(?P<username>[^/]+)/(?P<token>[^/]+)$")
func BuildEmailConfirmation(username, token string) string {
defer CatchPanic()
return Url(fmt.Sprintf("/email_confirmation/%s/%s", url.PathEscape(username), token), nil)
}
var RegexPasswordResetRequest = regexp.MustCompile("^/password_reset$")
func BuildPasswordResetRequest() string {
defer CatchPanic()
return Url("/password_reset", nil)
}
/*
@ -145,7 +173,12 @@ func BuildUserProfile(username string) string {
if len(username) == 0 {
panic(oops.New(nil, "Username must not be blank"))
}
return Url("/m/"+username, nil)
return Url("/m/"+url.PathEscape(username), nil)
}
// TODO
func BuildUserSettings(username string) string {
return ""
}
/*

View File

@ -352,7 +352,7 @@ func SeedFromFile(seedFile string, afterMigration types.MigrationVersion) {
Migrate(afterMigration)
fmt.Println("Executing seed...")
cmd := exec.Command("psql",
cmd := exec.Command("pg_restore",
"--single-transaction",
"--dbname",
config.Config.Postgres.DbName,
@ -361,7 +361,7 @@ func SeedFromFile(seedFile string, afterMigration types.MigrationVersion) {
"--username",
config.Config.Postgres.User,
"--password",
"-f",
"--data-only",
seedFile,
)
fmt.Println("Running command:", cmd)

View File

@ -5,6 +5,7 @@ import (
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4"
)

View File

@ -0,0 +1,79 @@
package migrations
import (
"context"
"fmt"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4"
)
func init() {
registerMigration(FixupUsersForRegistration{})
}
type FixupUsersForRegistration struct{}
func (m FixupUsersForRegistration) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 8, 8, 9, 36, 24, 0, time.UTC))
}
func (m FixupUsersForRegistration) Name() string {
return "FixupUsersForRegistration"
}
func (m FixupUsersForRegistration) Description() string {
return "Remove PendingUser and add the necessary fields to users"
}
func (m FixupUsersForRegistration) Up(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
ALTER TABLE auth_user
ADD status INT NOT NULL DEFAULT 1;
ALTER TABLE auth_user
ADD registration_ip INET;
ALTER TABLE auth_user
ALTER COLUMN is_staff SET DEFAULT FALSE;
ALTER TABLE auth_user
ALTER COLUMN timezone SET DEFAULT 'UTC';
ALTER TABLE auth_user
DROP first_name;
ALTER TABLE auth_user
DROP last_name;
ALTER TABLE auth_user
DROP color_1;
ALTER TABLE auth_user
DROP color_2;
`)
if err != nil {
return oops.New(err, "failed to modify auth_user")
}
fmt.Printf("Setting status on users.\n")
// status = INACTIVE(1) when !is_active && last_login is null
// ACTIVE(2) when is_active
// BANNED(3) when !is_active && last_login is not null
_, err = tx.Exec(ctx, `
UPDATE auth_user
SET status = CASE is_active WHEN TRUE THEN 2 ELSE (CASE WHEN last_login IS NULL THEN 1 ELSE 3 END) END;
`)
if err != nil {
return oops.New(err, "failed to set user status")
}
_, err = tx.Exec(ctx, `
ALTER TABLE auth_user
DROP is_active;
`)
if err != nil {
return oops.New(err, "failed to drop is_active")
}
return nil
}
func (m FixupUsersForRegistration) Down(ctx context.Context, tx pgx.Tx) error {
panic("Implement me")
}

View File

@ -0,0 +1,61 @@
package migrations
import (
"context"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4"
)
func init() {
registerMigration(AddDeletionIndices{})
}
type AddDeletionIndices struct{}
func (m AddDeletionIndices) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 8, 8, 11, 9, 26, 0, time.UTC))
}
func (m AddDeletionIndices) Name() string {
return "AddDeletionIndices"
}
func (m AddDeletionIndices) Description() string {
return "Add indices to tables that depend on auth_user to allow for faster deletion of users"
}
func (m AddDeletionIndices) Up(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `DROP TABLE auth_user_user_permissions;`)
if err != nil {
return oops.New(err, "failed to drop auth_user_user_permissions")
}
_, err = tx.Exec(ctx, `DROP TABLE django_admin_log;`)
if err != nil {
return oops.New(err, "failed to drop django_admin_log")
}
_, err = tx.Exec(ctx, `
CREATE INDEX handmade_communicationchoice_userid ON handmade_communicationchoice (user_id);
CREATE INDEX handmade_communicationsubcategory_userid ON handmade_communicationsubcategory (user_id);
CREATE INDEX handmade_communicationsubthread_userid ON handmade_communicationsubthread (user_id);
CREATE INDEX handmade_discord_hmnuserid ON handmade_discord (hmn_user_id);
CREATE INDEX handmade_links_userid ON handmade_links (user_id);
CREATE INDEX handmade_passwordresetrequest_userid ON handmade_passwordresetrequest (user_id);
CREATE INDEX handmade_post_authorid ON handmade_post (author_id);
CREATE INDEX handmade_postversion_editorid ON handmade_postversion (editor_id);
CREATE INDEX handmade_thread_personalarticleuserid ON handmade_thread (personal_article_user_id);
CREATE INDEX handmade_user_projects_userid ON handmade_user_projects (user_id);
`)
if err != nil {
return oops.New(err, "failed to create user_id indices")
}
return nil
}
func (m AddDeletionIndices) Down(ctx context.Context, tx pgx.Tx) error {
panic("Implement me")
}

View File

@ -0,0 +1,119 @@
package migrations
import (
"context"
"fmt"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4"
)
func init() {
registerMigration(DeleteInactiveUsers{})
}
type DeleteInactiveUsers struct{}
func (m DeleteInactiveUsers) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 8, 8, 13, 46, 55, 0, time.UTC))
}
func (m DeleteInactiveUsers) Name() string {
return "DeleteInactiveUsers"
}
func (m DeleteInactiveUsers) Description() string {
return "Delete inactive users and expired onetimetokens"
}
func (m DeleteInactiveUsers) Up(ctx context.Context, tx pgx.Tx) error {
var err error
res, err := tx.Exec(ctx,
`
DELETE FROM handmade_passwordresetrequest
USING handmade_onetimetoken
WHERE handmade_onetimetoken.expires < $1 AND handmade_passwordresetrequest.confirmation_token_id = handmade_onetimetoken.id;
`,
time.Now(),
)
if err != nil {
return oops.New(err, "failed to delete password reset requests")
}
fmt.Printf("Deleted %v expired password reset requests.\n", res.RowsAffected())
fmt.Printf("Deleting inactive users. This might take a minute.\n")
res, err = tx.Exec(ctx,
`
DELETE FROM auth_user
WHERE status = 1 AND date_joined < $1;
`,
time.Now().Add(-(time.Hour * 24 * 7)),
)
if err != nil {
return oops.New(err, "failed to delete inactive users")
}
fmt.Printf("Deleted %v inactive users.\n", res.RowsAffected())
_, err = tx.Exec(ctx, `
ALTER TABLE handmade_onetimetoken
DROP used;
ALTER TABLE handmade_onetimetoken
ADD owner_id INT REFERENCES auth_user(id) ON DELETE CASCADE;
ALTER TABLE handmade_userpending
DROP CONSTRAINT handma_activation_token_id_0b4a4b06_fk_handmade_onetimetoken_id;
`)
_, err = tx.Exec(ctx, `
UPDATE handmade_userpending
SET activation_token_id = NULL
WHERE (SELECT count(*) AS ct FROM handmade_onetimetoken WHERE id = activation_token_id) = 0;
`)
res, err = tx.Exec(ctx,
`
DELETE FROM handmade_onetimetoken
WHERE expires < $1
`,
time.Now(),
)
if err != nil {
return oops.New(err, "failed to delete expired tokens")
}
fmt.Printf("Deleted %v expired tokens.\n", res.RowsAffected())
fmt.Printf("Setting owner_id on onetimetoken\n")
_, err = tx.Exec(ctx, `
UPDATE handmade_onetimetoken
SET owner_id = (SELECT id FROM auth_user WHERE username = (SELECT username FROM handmade_userpending WHERE activation_token_id = handmade_onetimetoken.id LIMIT 1))
WHERE token_type = 1;
`)
if err != nil {
return oops.New(err, "failed to set owner_id on onetimetoken")
}
_, err = tx.Exec(ctx, `
UPDATE handmade_onetimetoken
SET owner_id = (SELECT user_id FROM handmade_passwordresetrequest WHERE confirmation_token_id = handmade_onetimetoken.id)
WHERE token_type = 2;
`)
if err != nil {
return oops.New(err, "failed to set owner_id on onetimetoken")
}
fmt.Printf("Setting registration_ip on auth_user\n")
_, err = tx.Exec(ctx, `
UPDATE auth_user
SET registration_ip = (SELECT ip FROM handmade_userpending WHERE handmade_userpending.username = auth_user.username LIMIT 1);
`)
if err != nil {
return oops.New(err, "failed to set owner_id on onetimetoken")
}
return nil
}
func (m DeleteInactiveUsers) Down(ctx context.Context, tx pgx.Tx) error {
panic("Implement me")
}

View File

@ -0,0 +1,60 @@
package migrations
import (
"context"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4"
)
func init() {
registerMigration(FinalizeOneTimeTokenChanges{})
}
type FinalizeOneTimeTokenChanges struct{}
func (m FinalizeOneTimeTokenChanges) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 8, 8, 14, 18, 19, 0, time.UTC))
}
func (m FinalizeOneTimeTokenChanges) Name() string {
return "FinalizeOneTimeTokenChanges"
}
func (m FinalizeOneTimeTokenChanges) Description() string {
return "Create an index and set not-null"
}
func (m FinalizeOneTimeTokenChanges) Up(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
ALTER TABLE handmade_onetimetoken
ALTER COLUMN owner_id SET NOT NULL;
CREATE INDEX handmade_onetimetoken_ownerid_type ON handmade_onetimetoken(owner_id, token_type);
`)
if err != nil {
return oops.New(err, "Create index on onetimetoken")
}
_, err = tx.Exec(ctx, `
DROP TABLE handmade_userpending;
`)
if err != nil {
return oops.New(err, "failed to drop handmade_userpending")
}
_, err = tx.Exec(ctx, `
DROP TABLE handmade_passwordresetrequest;
`)
if err != nil {
return oops.New(err, "failed to drop handmade_passwordresetrequest")
}
return nil
}
func (m FinalizeOneTimeTokenChanges) Down(ctx context.Context, tx pgx.Tx) error {
panic("Implement me")
}

View File

@ -0,0 +1,27 @@
package models
import (
"time"
"github.com/google/uuid"
)
type OneTimeTokenType int
const (
TokenTypeRegistration OneTimeTokenType = iota + 1
TokenTypePasswordReset
)
type OneTimeToken struct {
ID int `db:"id"`
OwnerID int `db:"owner_id"`
Type OneTimeTokenType `db:"token_type`
Created time.Time `db:"created"`
Expires time.Time `db:"expires"`
Content string `db:"token_content"`
}
func GenerateToken() string {
return uuid.New().String()
}

View File

@ -7,6 +7,14 @@ import (
var UserType = reflect.TypeOf(User{})
type UserStatus int
const (
UserStatusInactive UserStatus = iota + 1
UserStatusActive
UserStatusBanned
)
type User struct {
ID int `db:"id"`
@ -17,8 +25,8 @@ type User struct {
DateJoined time.Time `db:"date_joined"`
LastLogin *time.Time `db:"last_login"`
IsStaff bool `db:"is_staff"`
IsActive bool `db:"is_active"`
IsStaff bool `db:"is_staff"`
Status UserStatus `db:"status"`
Name string `db:"name"`
Bio string `db:"bio"`
@ -26,10 +34,8 @@ type User struct {
Signature string `db:"signature"`
Avatar *string `db:"avatar"` // TODO: Image field stuff?
DarkTheme bool `db:"darktheme"`
Timezone string `db:"timezone"`
ProfileColor1 string `db:"color_1"`
ProfileColor2 string `db:"color_2"`
DarkTheme bool `db:"darktheme"`
Timezone string `db:"timezone"`
ShowEmail bool `db:"showemail"`
CanEditLibrary bool `db:"edit_library"`
@ -39,3 +45,7 @@ type User struct {
MarkedAllReadAt time.Time `db:"marked_all_read_at"`
}
func (u *User) IsActive() bool {
return u.Status == UserStatusActive
}

View File

@ -2,7 +2,10 @@ package perf
import (
"context"
"fmt"
"time"
"github.com/rs/zerolog"
)
type RequestPerf struct {
@ -132,3 +135,15 @@ func (perfCollector *PerfCollector) GetPerfCopy() *PerfStorage {
perfStorageCopy := <-resultChan
return &perfStorageCopy
}
func LogPerf(perf *RequestPerf, log *zerolog.Event) {
blockStack := make([]time.Time, 0)
for i, block := range perf.Blocks {
for len(blockStack) > 0 && block.End.After(blockStack[len(blockStack)-1]) {
blockStack = blockStack[:len(blockStack)-1]
}
log.Str(fmt.Sprintf("[%4.d] At %9.2fms", i, perf.MsFromStart(&block)), fmt.Sprintf("%*.s[%s] %s (%.4fms)", len(blockStack)*2, "", block.Category, block.Description, block.DurationMs()))
blockStack = append(blockStack, block.End)
}
log.Msg(fmt.Sprintf("Served [%s] %s in %.4fms", perf.Method, perf.Path, float64(perf.End.Sub(perf.Start).Nanoseconds())/1000/1000))
}

View File

@ -33,3 +33,7 @@
.notice-success {
@include usevar(background-color, notice-success-color);
}
.notice-failure {
@include usevar(background-color, notice-failure-color);
}

View File

@ -50,6 +50,7 @@ $vars: (
notice-lts-color: #2a681d,
notice-lts-reqd-color: #876327,
notice-success-color: #2a681d,
notice-failure-color: #7a2020,
optionbar-border-color: #333,

View File

@ -50,6 +50,7 @@ $vars: (
notice-lts-color: #43a52f,
notice-lts-reqd-color: #aa7d30,
notice-success-color: #43a52f,
notice-failure-color: #b42222,
optionbar-border-color: #ccc,

View File

@ -160,10 +160,8 @@ func UserToTemplate(u *models.User, currentTheme string) User {
AvatarUrl: UserAvatarUrl(u, currentTheme),
ProfileUrl: hmnurl.BuildUserProfile(u.Username),
DarkTheme: u.DarkTheme,
Timezone: u.Timezone,
ProfileColor1: u.ProfileColor1,
ProfileColor2: u.ProfileColor2,
DarkTheme: u.DarkTheme,
Timezone: u.Timezone,
CanEditLibrary: u.CanEditLibrary,
DiscordSaveShowcase: u.DiscordSaveShowcase,

View File

@ -0,0 +1,19 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="content-block">
<form method="POST">
<input type="hidden" name="token" value="{{ .Token }}" />
<input style="visibility:hidden;" type="text" name="username" value="{{ .Username }}" required />
<h1>Hi, {{ .Username }}!</h1>
<p class="mb3 b">You're almost done signing up.</p>
<p>To complete your registration and log in, please enter the password you used during the registration process.</p>
{{/*NOTE: The username field isn't `type="hidden"` because this way browser will offer to save the username along with the password */}}
<label class="db b" for="password">Password</label>
<input class="db" type="password" name="password" minlength="8" required />
<input class="db mt3" type="submit" value="Complete registration" />
</form>
</div>
{{ end }}

View File

@ -0,0 +1,13 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="content-block">
<h1>Welcome to Handmade Network, {{ .User.Name }}</h1>
<p class="b">Here's some interesting stuff you can find here:</p>
<ul>
<li>We have an active <a href="{{ .DiscordUrl }}">Discord</a> community</li>
<li>We have a <a href="{{ .PodcastUrl }}">Podcast</a></li>
</ul>
{{/* TODO(asaf): Describe what users can do on the network? */}}
</div>
{{ end }}

View File

@ -0,0 +1,25 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="content-block">
<h1>Please enter your username and password</h1>
<form method="POST">
<input type="hidden" name="redirect" value="{{ .RedirectUrl }}" />
<p class="mb2">
<label class="db b" for="username">Username</label>
<input class="db w5" name="username" minlength="3" maxlength="30" type="text" required pattern="^[0-9a-zA-Z][\w-]{2,29}$" />
</p>
<p class="mb2">
<label class="db b" for="password">Password</label>
<input class="db w5" name="password" minlength="8" type="password" required />
</p>
<a href="{{ .ForgotPasswordUrl }}">Forgot your password?</a>
<p class="mt3">
<input type="submit" value="Log in" />
</p>
</form>
</div>
{{ end }}

View File

@ -0,0 +1,55 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="content-block">
<form id="register_form" method="post">
{{/* NOTE(asaf): No CSRF on register. We don't have a user session yet and I don't think we would gain anything from a pre-login session here */}}
<p class="mb2">
<label class="db b" for="username">Username</label>
<input class="db w5" name="username" minlength="3" maxlength="30" type="text" required pattern="^[0-9a-zA-Z][\w-]{2,29}$" />
<span class="note db">Required. You may use up to 30 characters. Must start with a letter or number. Dashes and underscores are allowed.</span>
</p>
<p class="mb2">
<label class="db b" for="displayname">Display name</label>
<input class="db w5" name="displayname" type="text" />
<span class="note db">Optional.</span>
</p>
<p class="mb2">
<label class="db b" for="email">Email</label>
<input class="db w5" name="email" type="email" required />
</p>
<p class="mb2">
<label class="db b" for="password">Password</label>
<input class="db w5" name="password" minlength="8" type="password" required />
</p>
<p class="mb2">
<label class="db b" for="password2">Password confirmation</label>
<input class="db w5" name="password2" minlength="8" type="password" required />
<span class="note db">Enter the same password as before, for verification.</span>
</p>
{{/* TODO(asaf): Consider adding some bot-mitigation thing here */}}
<p>
<input type="submit" value="Register" />
</p>
</form>
<script>
let form = document.querySelector("#register_form")
let password1 = form.querySelector("input[name=password]")
let password2 = form.querySelector("input[name=password2]")
password2.addEventListener("input", function(ev) {
if (password1.value != password2.value) {
password2.setCustomValidity("Password doesn't match");
} else {
password2.setCustomValidity("");
}
});
</script>
</div>
{{ end }}

View File

@ -0,0 +1,11 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="content-block">
<h1>Hi! You're almost done signing up.</h1>
<p class="mb3 b">We've sent you an email with a confirmation link. Please follow it to complete the registration process.</p>
<p>If for some reason the email doesn't arrive in a timely fashion and you also can't find it in your spam trap,<br />
you should feel free to <a href="{{ .ContactUsUrl }}">contact the staff</a> and ask us to activate you manually.<br />
You'll want to tell us the username you chose and preferably email us from the same address you used to sign up.</p>
</div>
{{ end }}

View File

@ -0,0 +1,13 @@
<p>Hello, {{ .Name }}</p>
<br />
<p>Welcome to <a href="{{ .HomepageUrl }}"><b>Handmade Network</b></a>.</p>
<p>To complete the registration process, please use the following link: <a href="{{ .CompleteRegistrationUrl }}"><b>{{ .CompleteRegistrationUrl }}</b></a>.</p>
<br />
<p>Thanks,<br />
The Handmade Network staff.</p>
<hr />
<p style="font-size:small; -webkit-text-size-adjust:none ;color: #666">
You are receiving this email because someone registered with your email address at <a href="{{ .HomepageUrl }}">Handmade.Network</a>.<br />
If that wasn't you, kindly ignore this email. If you do not complete the registration, your information will be deleted from our servers after 7 days.
</p>

View File

@ -80,6 +80,7 @@
<body class="{{ join " " .BodyClasses }}">
<div class="content mw-site ph3-m ph4-l">
{{ template "header.html" . }}
{{ template "notices.html" .Notices }}
{{ with .Breadcrumbs }}
<div class="tc tl-ns ph2 ph0-ns pb2 pb0-ns">
{{ range $i, $e := . -}}

View File

@ -2,7 +2,6 @@
{{ define "content" }}
<div class="content-block">
{{ template "notices.html" .Notices }}
<form id="podcast_form" method="POST" enctype="multipart/form-data">
{{ csrftoken .Session }}
<input class="b w-100 mb1" type="text" name="title" required placeholder="Podcast title..." value="{{ .Podcast.Title }}" />

View File

@ -2,7 +2,6 @@
{{ define "content" }}
<div class="content-block">
{{ template "notices.html" .Notices }}
<h1>{{ if .IsEdit }}Edit{{ else }}New{{ end }} Episode</h1>
<form method="POST">
{{ csrftoken .Session }}

View File

@ -9,7 +9,6 @@
{{ define "content" }}
<div class="flex flex-column flex-row-l">
<div class="flex-grow-1 overflow-hidden">
{{ template "notices.html" .Notices }}
{{ with .Screenshots }}
<div class="carousel-container mw-100 mv2 mv3-ns margin-center">
<div class="carousel aspect-ratio aspect-ratio--16x9 overflow-hidden bg--dim br2-ns">

View File

@ -13,6 +13,7 @@ type BaseData struct {
Theme string
BodyClasses []string
Breadcrumbs []Breadcrumb
Notices []Notice
CurrentUrl string
LoginPageUrl string
@ -128,10 +129,8 @@ type User struct {
AvatarUrl string
ProfileUrl string
DarkTheme bool
Timezone string
ProfileColor1 string
ProfileColor2 string
DarkTheme bool
Timezone string
CanEditLibrary bool
DiscordSaveShowcase bool
@ -172,6 +171,7 @@ type PodcastEpisode struct {
Duration int
}
// NOTE(asaf): See /src/rawdata/scss/_notices.scss for a list of classes.
type Notice struct {
Content template.HTML
Class string
@ -296,3 +296,10 @@ type Pagination struct {
PreviousUrl string
NextUrl string
}
type EmailBaseData struct {
To template.HTML
From template.HTML
Subject template.HTML
Separator template.HTML
}

View File

@ -317,6 +317,10 @@ func Index(c *RequestContext) ResponseData {
baseData := getBaseData(c)
baseData.BodyClasses = append(baseData.BodyClasses, "hmdev", "landing") // TODO: Is "hmdev" necessary any more?
if c.Req.URL.Query().Get("registered") != "" {
baseData.Notices = []templates.Notice{{Content: "You've completed your registration successfully!", Class: "success"}}
}
var res ResponseData
err = res.WriteTemplate("landing.html", LandingTemplateData{
BaseData: baseData,

View File

@ -2,17 +2,55 @@ package website
import (
"errors"
"fmt"
"html/template"
"net/http"
"regexp"
"strings"
"time"
"git.handmade.network/hmn/hmn/src/auth"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/email"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates"
)
// TODO(asaf): Add a middleware that guarantees the certain handlers will take at least X amount of time.
// Will be relevant for:
// * Login POST
// * Register POST
var UsernameRegex = regexp.MustCompile(`^[0-9a-zA-Z][\w-]{2,29}$`)
type LoginPageData struct {
templates.BaseData
RedirectUrl string
ForgotPasswordUrl string
}
func LoginPage(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
return RejectRequest(c, "You are already logged in.")
}
var res ResponseData
res.MustWriteTemplate("auth_login.html", LoginPageData{
BaseData: getBaseData(c),
RedirectUrl: c.Req.URL.Query().Get("redirect"),
ForgotPasswordUrl: hmnurl.BuildPasswordResetRequest(),
}, c.Perf)
return res
}
func Login(c *RequestContext) ResponseData {
// TODO: Update this endpoint to give uniform responses on errors and to be resilient to timing attacks.
if c.CurrentUser != nil {
return RejectRequest(c, "You are already logged in.")
}
form, err := c.GetFormValues()
if err != nil {
@ -22,7 +60,7 @@ func Login(c *RequestContext) ResponseData {
username := form.Get("username")
password := form.Get("password")
if username == "" || password == "" {
return ErrorResponse(http.StatusBadRequest, NewSafeError(err, "you must provide both a username and password"))
return RejectRequest(c, "You must provide both a username and password")
}
redirect := form.Get("redirect")
@ -33,52 +71,49 @@ func Login(c *RequestContext) ResponseData {
userRow, err := db.QueryOne(c.Context(), c.Conn, models.User{}, "SELECT $columns FROM auth_user WHERE LOWER(username) = LOWER($1)", username)
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
return ResponseData{
StatusCode: http.StatusUnauthorized,
}
var res ResponseData
baseData := getBaseData(c)
baseData.Notices = []templates.Notice{{Content: "Incorrect username or password", Class: "failure"}}
res.MustWriteTemplate("auth_login.html", LoginPageData{
BaseData: baseData,
RedirectUrl: redirect,
ForgotPasswordUrl: hmnurl.BuildPasswordResetRequest(),
}, c.Perf)
return res
} else {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up user by username"))
}
}
user := userRow.(*models.User)
hashed, err := auth.ParsePasswordString(user.Password)
success, err := tryLogin(c, user, password)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse password string"))
return ErrorResponse(http.StatusInternalServerError, err)
}
passwordsMatch, err := auth.CheckPassword(password, hashed)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check password against hash"))
}
if passwordsMatch {
// re-hash and save the user's password if necessary
if hashed.IsOutdated() {
newHashed, err := auth.HashPassword(password)
if err == nil {
err := auth.UpdatePassword(c.Context(), c.Conn, user.Username, newHashed)
if err != nil {
c.Logger.Error().Err(err).Msg("failed to update user's password")
}
} else {
c.Logger.Error().Err(err).Msg("failed to re-hash password")
}
// If errors happen here, we can still continue with logging them in
}
session, err := auth.CreateSession(c.Context(), c.Conn, user.Username)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create session"))
}
res := c.Redirect(redirect, http.StatusSeeOther)
res.SetCookie(auth.NewSessionCookie(session))
if !success {
var res ResponseData
baseData := getBaseData(c)
baseData.Notices = []templates.Notice{{Content: "Incorrect username or password", Class: "failure"}}
res.MustWriteTemplate("auth_login.html", LoginPageData{
BaseData: baseData,
RedirectUrl: redirect,
ForgotPasswordUrl: hmnurl.BuildPasswordResetRequest(),
}, c.Perf)
return res
} else {
return c.Redirect("/", http.StatusSeeOther) // TODO: Redirect to standalone login page with error
}
if user.Status == models.UserStatusInactive {
return RejectRequest(c, "You must validate your email address before logging in. You should've received an email shortly after registration. If you did not receive the email, please contact the staff.")
}
res := c.Redirect(redirect, http.StatusSeeOther)
err = loginUser(c, user, &res)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, err)
}
return res
}
func Logout(c *RequestContext) ResponseData {
@ -101,3 +136,452 @@ func Logout(c *RequestContext) ResponseData {
return res
}
func RegisterNewUser(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
c.Redirect(hmnurl.BuildUserSettings(c.CurrentUser.Username), http.StatusSeeOther)
}
// TODO(asaf): Do something to prevent bot registration
var res ResponseData
res.MustWriteTemplate("auth_register.html", getBaseData(c), c.Perf)
return res
}
func RegisterNewUserSubmit(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
return RejectRequest(c, "Can't register new user. You are already logged in")
}
c.Req.ParseForm()
username := strings.TrimSpace(c.Req.Form.Get("username"))
displayName := strings.TrimSpace(c.Req.Form.Get("displayname"))
emailAddress := strings.TrimSpace(c.Req.Form.Get("email"))
password := c.Req.Form.Get("password")
password2 := c.Req.Form.Get("password2")
if !UsernameRegex.Match([]byte(username)) {
return RejectRequest(c, "Invalid username")
}
if !email.IsEmail(emailAddress) {
return RejectRequest(c, "Invalid email address")
}
if len(password) < 8 {
return RejectRequest(c, "Password too short")
}
if password != password2 {
return RejectRequest(c, "Password confirmation doesn't match password")
}
c.Perf.StartBlock("SQL", "Check for existing usernames and emails")
userAlreadyExists := true
_, err := db.QueryInt(c.Context(), c.Conn,
`
SELECT id
FROM auth_user
WHERE LOWER(username) = LOWER($1)
`,
username,
)
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
userAlreadyExists = false
} else {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user"))
}
}
if userAlreadyExists {
return RejectRequest(c, fmt.Sprintf("Username (%s) already exists.", username))
}
emailAlreadyExists := true
_, err = db.QueryInt(c.Context(), c.Conn,
`
SELECT id
FROM auth_user
WHERE LOWER(email) = LOWER($1)
`,
emailAddress,
)
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
emailAlreadyExists = false
} else {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user"))
}
}
c.Perf.EndBlock()
if emailAlreadyExists {
// NOTE(asaf): Silent rejection so we don't allow attackers to harvest emails.
time.Sleep(time.Second * 3) // NOTE(asaf): Pretend to send email
return c.Redirect(hmnurl.BuildRegistrationSuccess(), http.StatusSeeOther)
}
hashed, err := auth.HashPassword(password)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to encrypt password"))
}
c.Perf.StartBlock("SQL", "Check blacklist")
// TODO(asaf): Check email against blacklist
blacklisted := false
if blacklisted {
// NOTE(asaf): Silent rejection so we don't allow attackers to harvest emails.
time.Sleep(time.Second * 3) // NOTE(asaf): Pretend to send email
return c.Redirect(hmnurl.BuildRegistrationSuccess(), http.StatusSeeOther)
}
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Create user and one time token")
tx, err := c.Conn.Begin(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to start db transaction"))
}
defer tx.Rollback(c.Context())
now := time.Now()
var newUserId int
err = tx.QueryRow(c.Context(),
`
INSERT INTO auth_user (username, email, password, date_joined, name, registration_ip)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`,
username, emailAddress, hashed.String(), now, displayName, c.GetIP(),
).Scan(&newUserId)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to store user"))
}
ott := models.GenerateToken()
_, err = tx.Exec(c.Context(),
`
INSERT INTO handmade_onetimetoken (token_type, created, expires, token_content, owner_id)
VALUES($1, $2, $3, $4, $5)
`,
models.TokenTypeRegistration,
now,
now.Add(time.Hour*24*7),
ott,
newUserId,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to store one-time token"))
}
c.Perf.EndBlock()
mailName := displayName
if mailName == "" {
mailName = username
}
err = email.SendRegistrationEmail(emailAddress, mailName, username, ott, c.Perf)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to send registration email"))
}
c.Perf.StartBlock("SQL", "Commit user")
err = tx.Commit(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to commit user to the db"))
}
c.Perf.EndBlock()
return c.Redirect(hmnurl.BuildRegistrationSuccess(), http.StatusSeeOther)
}
type RegisterNewUserSuccessData struct {
templates.BaseData
ContactUsUrl string
}
func RegisterNewUserSuccess(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
}
var res ResponseData
res.MustWriteTemplate("auth_register_success.html", RegisterNewUserSuccessData{
BaseData: getBaseData(c),
ContactUsUrl: hmnurl.BuildContactPage(),
}, c.Perf)
return res
}
type EmailValidationData struct {
templates.BaseData
Token string
Username string
}
func EmailConfirmation(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
}
username, hasUsername := c.PathParams["username"]
if !hasUsername {
return RejectRequest(c, "Bad validation url")
}
token := ""
hasToken := false
// TODO(asaf): Delete old hash/nonce about a week after launch
hash, hasHash := c.PathParams["hash"]
nonce, hasNonce := c.PathParams["nonce"]
if hasHash && hasNonce {
token = fmt.Sprintf("%s/%s", hash, nonce)
hasToken = true
} else {
token, hasToken = c.PathParams["token"]
}
if !hasToken {
return RejectRequest(c, "Bad validation url")
}
validationResult := validateUsernameAndToken(c, username, token)
if !validationResult.IsValid {
return validationResult.InvalidResponse
}
var res ResponseData
res.MustWriteTemplate("auth_email_validation.html", EmailValidationData{
BaseData: getBaseData(c),
Token: token,
Username: username,
}, c.Perf)
return res
}
func EmailConfirmationSubmit(c *RequestContext) ResponseData {
c.Req.ParseForm()
token := c.Req.Form.Get("token")
username := c.Req.Form.Get("username")
password := c.Req.Form.Get("password")
validationResult := validateUsernameAndToken(c, username, token)
if !validationResult.IsValid {
return validationResult.InvalidResponse
}
success, err := tryLogin(c, validationResult.User, password)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, err)
} else if !success {
var res ResponseData
baseData := getBaseData(c)
// NOTE(asaf): We can report that the password is incorrect, because an attacker wouldn't have a valid token to begin with.
baseData.Notices = []templates.Notice{{Content: template.HTML("Incorrect password. Please try again."), Class: "failure"}}
res.MustWriteTemplate("auth_email_validation.html", EmailValidationData{
BaseData: getBaseData(c),
Token: token,
Username: username,
}, c.Perf)
return res
}
c.Perf.StartBlock("SQL", "Updating user status and deleting token")
tx, err := c.Conn.Begin(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to start db transaction"))
}
defer tx.Rollback(c.Context())
_, err = tx.Exec(c.Context(),
`
UPDATE auth_user
SET status = $1
WHERE id = $2
`,
models.UserStatusActive,
validationResult.User.ID,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user status"))
}
_, err = tx.Exec(c.Context(),
`
DELETE FROM handmade_onetimetoken WHERE id = $1
`,
validationResult.OneTimeToken.ID,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete one time token"))
}
err = tx.Commit(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to commit transaction"))
}
c.Perf.EndBlock()
res := c.Redirect(hmnurl.BuildHomepageWithRegistrationSuccess(), http.StatusSeeOther)
err = loginUser(c, validationResult.User, &res)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, err)
}
return res
}
// NOTE(asaf): PasswordReset refers specifically to "forgot your password" flow over email,
// not to changing your password through the user settings page.
func RequestPasswordReset(c *RequestContext) ResponseData {
// Verify not logged in
// Show form
return FourOhFour(c)
}
func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
// Verify not logged in
// Verify email input
// Potentially send password reset email if no password reset request is active
// Show success page in all cases
return FourOhFour(c)
}
func PasswordReset(c *RequestContext) ResponseData {
// Verify reset token
// If logged in
// If same user as reset request -> Mark password reset as resolved and redirect to user settings
// If other user -> Log out
// Show form
return FourOhFour(c)
}
func PasswordResetSubmit(c *RequestContext) ResponseData {
// Verify not logged in
// Verify reset token
// Verify not resolved
// Verify inputs
// Set new password
// Mark resolved
// Log in
// Redirect to user settings page
return FourOhFour(c)
}
func tryLogin(c *RequestContext, user *models.User, password string) (bool, error) {
c.Perf.StartBlock("AUTH", "Checking password")
defer c.Perf.EndBlock()
hashed, err := auth.ParsePasswordString(user.Password)
if err != nil {
return false, oops.New(err, "failed to parse password string")
}
passwordsMatch, err := auth.CheckPassword(password, hashed)
if err != nil {
return false, oops.New(err, "failed to check password against hash")
}
if !passwordsMatch {
return false, nil
}
// re-hash and save the user's password if necessary
if hashed.IsOutdated() {
newHashed, err := auth.HashPassword(password)
if err == nil {
err := auth.UpdatePassword(c.Context(), c.Conn, user.Username, newHashed)
if err != nil {
c.Logger.Error().Err(err).Msg("failed to update user's password")
}
} else {
c.Logger.Error().Err(err).Msg("failed to re-hash password")
}
// If errors happen here, we can still continue with logging them in
}
return true, nil
}
func loginUser(c *RequestContext, user *models.User, responseData *ResponseData) error {
c.Perf.StartBlock("SQL", "Setting last login and creating session")
defer c.Perf.EndBlock()
tx, err := c.Conn.Begin(c.Context())
if err != nil {
return oops.New(err, "failed to start db transaction")
}
defer tx.Rollback(c.Context())
now := time.Now()
_, err = tx.Exec(c.Context(),
`
UPDATE auth_user
SET last_login = $1
WHERE id = $2
`,
now,
user.ID,
)
if err != nil {
return oops.New(err, "failed to update last_login for user")
}
session, err := auth.CreateSession(c.Context(), c.Conn, user.Username)
if err != nil {
return oops.New(err, "failed to create session")
}
err = tx.Commit(c.Context())
if err != nil {
return oops.New(err, "failed to commit transaction")
}
responseData.SetCookie(auth.NewSessionCookie(session))
return nil
}
type validateUserAndTokenResult struct {
User *models.User
OneTimeToken *models.OneTimeToken
IsValid bool
InvalidResponse ResponseData
}
func validateUsernameAndToken(c *RequestContext, username string, token string) validateUserAndTokenResult {
c.Perf.StartBlock("SQL", "Check username and token")
defer c.Perf.EndBlock()
type userAndTokenQuery struct {
User models.User `db:"auth_user"`
OneTimeToken *models.OneTimeToken `db:"onetimetoken"`
}
row, err := db.QueryOne(c.Context(), c.Conn, userAndTokenQuery{},
`
SELECT $columns
FROM auth_user
LEFT JOIN handmade_onetimetoken AS onetimetoken ON onetimetoken.owner_id = auth_user.id
WHERE LOWER(auth_user.username) = LOWER($1)
`,
username,
)
var result validateUserAndTokenResult
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
result.IsValid = false
result.InvalidResponse = RejectRequest(c, "You haven't validated your email in time and your user was deleted. You may try registering again with the same username.")
} else {
result.IsValid = false
result.InvalidResponse = ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch token from db"))
}
} else {
data := row.(*userAndTokenQuery)
if data.OneTimeToken == nil {
// NOTE(asaf): The user exists, but the validation token doesn't. That means the user already validated their email and can just log in normally.
result.IsValid = false
result.InvalidResponse = c.Redirect(hmnurl.BuildLoginPage(""), http.StatusSeeOther)
} else if data.OneTimeToken.Content != token {
result.IsValid = false
result.InvalidResponse = RejectRequest(c, "Bad token. If you are having problems registering or logging in, please contact the staff.")
} else {
result.IsValid = true
result.User = &data.User
result.OneTimeToken = data.OneTimeToken
}
}
return result
}

View File

@ -74,7 +74,6 @@ func PodcastIndex(c *RequestContext) ResponseData {
type PodcastEditData struct {
templates.BaseData
Podcast templates.Podcast
Notices []templates.Notice
}
func PodcastEdit(c *RequestContext) ResponseData {
@ -102,7 +101,7 @@ func PodcastEdit(c *RequestContext) ResponseData {
success := c.URL().Query().Get("success")
if success != "" {
podcastEditData.Notices = append(podcastEditData.Notices, templates.Notice{Class: "success", Content: "Podcast updated successfully."})
podcastEditData.BaseData.Notices = append(podcastEditData.BaseData.Notices, templates.Notice{Class: "success", Content: "Podcast updated successfully."})
}
var res ResponseData
@ -312,7 +311,6 @@ type PodcastEpisodeEditData struct {
EpisodeNumber int
CurrentFile string
EpisodeFiles []string
Notices []templates.Notice
}
func PodcastEpisodeNew(c *RequestContext) ResponseData {
@ -392,7 +390,7 @@ func PodcastEpisodeEdit(c *RequestContext) ResponseData {
success := c.URL().Query().Get("success")
if success != "" {
podcastEpisodeEditData.Notices = append(podcastEpisodeEditData.Notices, templates.Notice{Class: "success", Content: "Podcast episode updated successfully."})
podcastEpisodeEditData.BaseData.Notices = append(podcastEpisodeEditData.BaseData.Notices, templates.Notice{Class: "success", Content: "Podcast episode updated successfully."})
}
var res ResponseData

View File

@ -209,7 +209,6 @@ type ProjectHomepageData struct {
templates.BaseData
Project templates.Project
Owners []templates.User
Notices []templates.Notice
Screenshots []string
ProjectLinks []templates.Link
Licenses []templates.Link
@ -385,7 +384,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
Class: "hidden",
Content: "NOTICE: This project is hidden. It is currently visible only to owners and site admins.",
}
projectHomepageData.Notices = append(projectHomepageData.Notices, hiddenNotice)
projectHomepageData.BaseData.Notices = append(projectHomepageData.BaseData.Notices, hiddenNotice)
}
if project.Lifecycle != models.ProjectLifecycleActive {
@ -413,7 +412,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
lifecycleNotice.Class = "lts"
lifecycleNotice.Content = template.HTML("NOTICE: This project has reached a state of completion.")
}
projectHomepageData.Notices = append(projectHomepageData.Notices, lifecycleNotice)
projectHomepageData.BaseData.Notices = append(projectHomepageData.BaseData.Notices, lifecycleNotice)
}
for _, screenshot := range screenshotQueryResult.ToSlice() {

View File

@ -6,6 +6,7 @@ import (
"fmt"
"html"
"io"
"net"
"net/http"
"net/url"
"path"
@ -142,6 +143,37 @@ func (c *RequestContext) FullUrl() string {
return scheme + c.Req.Host + c.Req.URL.String()
}
func (c *RequestContext) GetIP() *net.IPNet {
ipString := ""
if ipString == "" {
cf, hasCf := c.Req.Header["CF-Connecting-IP"]
if hasCf {
ipString = cf[0]
}
}
if ipString == "" {
forwarded, hasForwarded := c.Req.Header["X-Forwarded-For"]
if hasForwarded {
ipString = forwarded[0]
}
}
if ipString == "" {
ipString = c.Req.RemoteAddr
}
if ipString != "" {
_, res, err := net.ParseCIDR(fmt.Sprintf("%s/32", ipString))
if err == nil {
return res
}
}
return nil
}
func (c *RequestContext) GetFormValues() (url.Values, error) {
err := c.Req.ParseForm()
if err != nil {

View File

@ -116,10 +116,6 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
}
}
// TODO(asaf): login/logout shouldn't happen on subdomains. We should verify that in the middleware.
routes.POST(hmnurl.RegexLoginAction, Login)
routes.GET(hmnurl.RegexLogoutAction, Logout)
routes.GET(hmnurl.RegexPublic, func(c *RequestContext) ResponseData {
var res ResponseData
http.StripPrefix("/public/", http.FileServer(http.Dir("public"))).ServeHTTP(&res, c.Req)
@ -144,6 +140,19 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
// TODO(asaf): Have separate middleware for HMN-only routes and any-project routes
// NOTE(asaf): HMN-only routes:
mainRoutes.GET(hmnurl.RegexOldHome, Index)
mainRoutes.POST(hmnurl.RegexLoginAction, Login)
mainRoutes.GET(hmnurl.RegexLogoutAction, Logout)
mainRoutes.GET(hmnurl.RegexLoginPage, LoginPage)
mainRoutes.GET(hmnurl.RegexRegister, RegisterNewUser)
mainRoutes.POST(hmnurl.RegexRegister, RegisterNewUserSubmit)
mainRoutes.GET(hmnurl.RegexRegistrationSuccess, RegisterNewUserSuccess)
mainRoutes.GET(hmnurl.RegexOldEmailConfirmation, EmailConfirmation) // TODO(asaf): Delete this a bit after launch
mainRoutes.GET(hmnurl.RegexEmailConfirmation, EmailConfirmation)
mainRoutes.POST(hmnurl.RegexEmailConfirmation, EmailConfirmationSubmit)
mainRoutes.GET(hmnurl.RegexFeed, Feed)
mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed)
mainRoutes.GET(hmnurl.RegexShowcase, Showcase)
@ -229,7 +238,7 @@ func getBaseData(c *RequestContext) templates.BaseData {
UserSettingsUrl: hmnurl.BuildHomepage(), // TODO(asaf)
LoginActionUrl: hmnurl.BuildLoginAction(c.FullUrl()),
LogoutActionUrl: hmnurl.BuildLogoutAction(c.FullUrl()),
RegisterUrl: hmnurl.BuildHomepage(), // TODO(asaf)
RegisterUrl: hmnurl.BuildRegister(),
HMNHomepageUrl: hmnurl.BuildHomepage(),
ProjectHomepageUrl: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug),
ProjectIndexUrl: hmnurl.BuildProjectIndex(1),

View File

@ -40,6 +40,7 @@ var WebsiteCommand = &cobra.Command{
backgroundJobsDone := zipJobs(
auth.PeriodicallyDeleteExpiredSessions(backgroundJobContext, conn),
auth.PeriodicallyDeleteInactiveUsers(backgroundJobContext, conn),
perfCollector.Done,
)