Add Discord login (#106)

This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt.

When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email.

Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account.

Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place.

If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account.

(It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.)

Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows.

Co-authored-by: Ben Visness <bvisness@gmail.com>
Reviewed-on: hmn/hmn#106
This commit is contained in:
bvisness 2023-05-06 19:38:50 +00:00
parent 1b1c25da80
commit 0210a0784b
24 changed files with 586 additions and 148 deletions

24
public/discord-login.svg Normal file
View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 256 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.402516,0,0,0.410256,60.3774,109.538)">
<rect x="-150" y="-267" width="636" height="117" style="fill:rgb(88,101,242);"/>
</g>
<g transform="matrix(0.266745,0,0,0.266745,60.1574,11.1482)">
<g id="图层_2">
<g id="Discord_Logos">
<g id="Discord_Logo_-_Large_-_White">
<path d="M170.85,20.2L198.15,20.2C204.73,20.2 210.297,21.227 214.85,23.28C219.042,25.042 222.602,28.034 225.06,31.86C227.361,35.639 228.54,39.996 228.46,44.42C228.511,48.864 227.285,53.231 224.93,57C222.303,60.982 218.545,64.088 214.14,65.92C209.313,68.12 203.33,69.217 196.19,69.21L170.85,69.21L170.85,20.2ZM195.91,56.74C200.343,56.74 203.75,55.633 206.13,53.42C208.57,51.054 209.873,47.745 209.7,44.35C209.85,41.202 208.699,38.127 206.52,35.85C204.387,33.73 201.177,32.667 196.89,32.66L188.35,32.66L188.35,56.74L195.91,56.74Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M269.34,69.13C265.738,68.228 262.293,66.788 259.12,64.86L259.12,53.24C261.89,55.255 265.009,56.74 268.32,57.62C271.942,58.725 275.704,59.301 279.49,59.33C280.797,59.4 282.102,59.174 283.31,58.67C284.17,58.23 284.6,57.67 284.6,57.09C284.619,56.435 284.365,55.801 283.9,55.34C283.101,54.726 282.164,54.317 281.17,54.15L272.77,52.26C267.957,51.14 264.54,49.59 262.52,47.61C260.471,45.543 259.381,42.707 259.52,39.8C259.486,37.194 260.437,34.668 262.18,32.73C264.248,30.543 266.855,28.939 269.74,28.08C273.452,26.913 277.329,26.356 281.22,26.43C284.852,26.396 288.474,26.819 292,27.69C294.848,28.365 297.583,29.449 300.12,30.91L300.12,41.91C297.741,40.53 295.184,39.483 292.52,38.8C289.634,38.026 286.658,37.636 283.67,37.64C279.283,37.64 277.09,38.387 277.09,39.88C277.075,40.559 277.47,41.183 278.09,41.46C279.289,41.97 280.544,42.332 281.83,42.54L288.83,43.8C293.377,44.6 296.767,46 299,48C301.233,50 302.353,52.927 302.36,56.78C302.432,60.898 300.308,64.758 296.79,66.9C293.103,69.373 287.84,70.607 281,70.6C277.067,70.606 273.149,70.112 269.34,69.13Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M318.9,67.66C315.158,65.941 312.003,63.158 309.83,59.66C307.803,56.23 306.764,52.304 306.83,48.32C306.77,44.338 307.864,40.423 309.98,37.05C312.235,33.607 315.447,30.896 319.22,29.25C323.807,27.251 328.778,26.282 333.78,26.41C340.78,26.41 346.59,27.88 351.21,30.82L351.21,43.65C349.452,42.468 347.532,41.549 345.51,40.92C343.246,40.205 340.884,39.851 338.51,39.87C334.17,39.87 330.773,40.663 328.32,42.25C326.06,43.511 324.655,45.905 324.655,48.493C324.655,51.041 326.016,53.403 328.22,54.68C330.6,56.287 334.053,57.09 338.58,57.09C340.916,57.096 343.241,56.759 345.48,56.09C347.522,55.504 349.484,54.668 351.32,53.6L351.32,66C345.91,69.154 339.731,70.754 333.47,70.62C328.451,70.757 323.467,69.744 318.9,67.66Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M368.64,67.66C364.856,65.932 361.645,63.155 359.39,59.66C357.265,56.238 356.161,52.278 356.21,48.25C356.142,44.27 357.248,40.356 359.39,37C361.657,33.594 364.854,30.907 368.6,29.26C377.799,25.528 388.101,25.528 397.3,29.26C401.031,30.894 404.215,33.568 406.47,36.96C408.597,40.328 409.693,44.247 409.62,48.23C409.67,52.254 408.577,56.211 406.47,59.64C404.239,63.138 401.044,65.917 397.27,67.64C388.133,71.558 377.777,71.558 368.64,67.64L368.64,67.66ZM389.91,55.24C391.657,53.436 392.584,50.989 392.47,48.48C392.592,45.994 391.662,43.568 389.91,41.8C388.01,40.058 385.483,39.159 382.91,39.31C380.339,39.175 377.818,40.072 375.91,41.8C374.164,43.571 373.239,45.996 373.36,48.48C373.247,50.987 374.169,53.433 375.91,55.24C377.8,57.004 380.328,57.925 382.91,57.79C385.494,57.94 388.028,57.017 389.91,55.24Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M451.69,29L451.69,44.14C449.6,42.893 447.191,42.285 444.76,42.39C441.03,42.39 438.15,43.53 436.15,45.79C434.15,48.05 433.15,51.56 433.15,56.32L433.15,69.2L416,69.2L416,28.25L432.8,28.25L432.8,41.25C433.733,36.49 435.24,32.98 437.32,30.72C439.36,28.472 442.286,27.228 445.32,27.32C447.56,27.259 449.771,27.842 451.69,29Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M508.67,18.8L508.67,69.2L491.52,69.2L491.52,60C490.227,63.275 487.903,66.041 484.9,67.88C481.585,69.769 477.814,70.71 474,70.6C470.411,70.684 466.878,69.698 463.85,67.77C460.918,65.865 458.581,63.172 457.11,60C455.504,56.499 454.705,52.681 454.77,48.83C454.657,44.837 455.508,40.875 457.25,37.28C458.851,33.988 461.351,31.214 464.46,29.28C467.656,27.344 471.334,26.349 475.07,26.41C483.23,26.41 488.713,29.957 491.52,37.05L491.52,18.8L508.67,18.8ZM489,55C490.766,53.261 491.722,50.857 491.63,48.38C491.705,45.976 490.747,43.652 489,42C484.971,38.719 479.139,38.719 475.11,42C473.368,43.69 472.425,46.045 472.52,48.47C472.431,50.918 473.384,53.292 475.14,55C476.995,56.725 479.47,57.63 482,57.51C484.575,57.652 487.102,56.746 489,55Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M107.7,8.07C99.347,4.246 90.528,1.533 81.47,0C80.23,2.216 79.109,4.496 78.11,6.83C68.461,5.376 58.649,5.376 49,6.83C48.001,4.496 46.879,2.216 45.64,-0C36.576,1.546 27.752,4.265 19.39,8.09C2.79,32.65 -1.71,56.6 0.54,80.21C10.261,87.392 21.143,92.855 32.71,96.36C35.315,92.857 37.62,89.14 39.6,85.25C35.838,83.845 32.208,82.112 28.75,80.07C29.66,79.41 30.55,78.73 31.41,78.07C51.768,87.644 75.372,87.644 95.73,78.07C96.6,78.78 97.49,79.46 98.39,80.07C94.926,82.115 91.288,83.852 87.52,85.26C89.498,89.149 91.803,92.862 94.41,96.36C105.987,92.869 116.877,87.409 126.6,80.22C129.24,52.84 122.09,29.11 107.7,8.07ZM42.45,65.69C36.18,65.69 31,60 31,53C31,46 36,40.26 42.43,40.26C48.86,40.26 54,46 53.89,53C53.78,60 48.84,65.69 42.45,65.69ZM84.69,65.69C78.41,65.69 73.25,60 73.25,53C73.25,46 78.25,40.26 84.69,40.26C91.13,40.26 96.23,46 96.12,53C96.01,60 91.08,65.69 84.69,65.69Z" style="fill:white;fill-rule:nonzero;"/>
<ellipse cx="242.92" cy="24.93" rx="8.55" ry="7.68" style="fill:white;"/>
<path d="M234.36,37.9C239.829,40.199 246.001,40.199 251.47,37.9L251.47,69.42L234.36,69.42L234.36,37.9Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@ -4602,7 +4602,7 @@ code, .code {
.pa2, .tab, figure, header .root-item > a, header .submenu > a {
padding: 0.5rem; }
.pa3, header #login-popup {
.pa3 {
padding: 1rem; }
.pa4 {
@ -7422,7 +7422,7 @@ article code {
color: #ccc;
color: var(--theme-color-dimmest); }
.b--dimmest, .optionbar, blockquote, .post-content th, .post-content td, header #login-popup {
.b--dimmest, .optionbar, blockquote, .post-content th, .post-content td {
border-color: #bbb;
border-color: var(--dimmest-color); }
@ -8936,25 +8936,19 @@ header #login-popup {
background-color: var(--login-popup-background);
color: black;
color: var(--fg-font-color);
border-width: 1px;
border-style: dashed;
visibility: hidden;
position: absolute;
z-index: 12;
margin-top: 10px;
right: 0px;
top: 20px;
width: 290px;
max-height: 0px;
overflow: hidden;
opacity: 0;
transition: all 0.2s; }
right: 0;
top: 100%;
width: 100%; }
header #login-popup.open {
max-height: 170px;
opacity: 1;
visibility: visible; }
header #login-popup label {
padding-right: 10px; }
@media screen and (min-width: 35em) {
header #login-popup {
top: 2.2rem;
width: 17rem; } }
@font-face {
font-family: icons;

View File

@ -24,7 +24,7 @@ const CSRFFieldName = "csrf_token"
const sessionDuration = time.Hour * 24 * 14
func makeSessionId() string {
func MakeSessionId() string {
idBytes := make([]byte, 40)
_, err := io.ReadFull(rand.Reader, idBytes)
if err != nil {
@ -47,7 +47,16 @@ func makeCSRFToken() string {
var ErrNoSession = errors.New("no session found")
func GetSession(ctx context.Context, conn *pgxpool.Pool, id string) (*models.Session, error) {
sess, err := db.QueryOne[models.Session](ctx, conn, "SELECT $columns FROM session WHERE id = $1", id)
sess, err := db.QueryOne[models.Session](ctx, conn,
`
SELECT $columns
FROM session
WHERE
id = $1
AND expires_at > CURRENT_TIMESTAMP
`,
id,
)
if err != nil {
if errors.Is(err, db.NotFound) {
return nil, ErrNoSession
@ -61,7 +70,7 @@ func GetSession(ctx context.Context, conn *pgxpool.Pool, id string) (*models.Ses
func CreateSession(ctx context.Context, conn *pgxpool.Pool, username string) (*models.Session, error) {
session := models.Session{
ID: makeSessionId(),
ID: MakeSessionId(),
Username: username,
ExpiresAt: time.Now().Add(sessionDuration),
CSRFToken: makeCSRFToken(),
@ -134,7 +143,16 @@ func DeleteExpiredSessions(ctx context.Context, conn *pgxpool.Pool) (int64, erro
return tag.RowsAffected(), nil
}
func PeriodicallyDeleteExpiredSessions(ctx context.Context, conn *pgxpool.Pool) jobs.Job {
func DeleteExpiredPendingLogins(ctx context.Context, conn *pgxpool.Pool) (int64, error) {
tag, err := conn.Exec(ctx, "DELETE FROM pending_login WHERE expires_at <= CURRENT_TIMESTAMP")
if err != nil {
return 0, oops.New(err, "failed to delete expired pending logins")
}
return tag.RowsAffected(), nil
}
func PeriodicallyDeleteExpiredStuff(ctx context.Context, conn *pgxpool.Pool) jobs.Job {
job := jobs.New()
go func() {
defer job.Done()
@ -145,6 +163,7 @@ func PeriodicallyDeleteExpiredSessions(ctx context.Context, conn *pgxpool.Pool)
case <-t.C:
err := func() (err error) {
defer utils.RecoverPanicAsError(&err)
n, err := DeleteExpiredSessions(ctx, conn)
if err == nil {
if n > 0 {
@ -153,10 +172,20 @@ func PeriodicallyDeleteExpiredSessions(ctx context.Context, conn *pgxpool.Pool)
} else {
logging.Error().Err(err).Msg("Failed to delete expired sessions")
}
n, err = DeleteExpiredPendingLogins(ctx, conn)
if err == nil {
if n > 0 {
logging.Info().Int64("num deleted pending logins", n).Msg("Deleted expired pending logins")
}
} else {
logging.Error().Err(err).Msg("Failed to delete expired pending logins")
}
return nil
}()
if err != nil {
logging.Error().Err(err).Msg("Panicked in PeriodicallyDeleteExpiredSessions")
logging.Error().Err(err).Msg("Panicked in PeriodicallyDeleteExpiredStuff")
}
case <-ctx.Done():
return

View File

@ -431,7 +431,7 @@ var discordDownloadClient = &http.Client{
type DiscordResourceBadStatusCode error
func downloadDiscordResource(ctx context.Context, url string) ([]byte, string, error) {
func DownloadDiscordResource(ctx context.Context, url string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", oops.New(err, "failed to make Discord download request")
@ -491,7 +491,7 @@ func saveAttachment(
height = *attachment.Height
}
content, _, err := downloadDiscordResource(ctx, attachment.Url)
content, _, err := DownloadDiscordResource(ctx, attachment.Url)
if err != nil {
return nil, oops.New(err, "failed to download Discord attachment")
}
@ -561,7 +561,7 @@ func saveEmbed(
}
maybeSaveImageish := func(i EmbedImageish, contentTypeCheck func(string) bool) (*uuid.UUID, error) {
content, contentType, err := downloadDiscordResource(ctx, *i.Url)
content, contentType, err := DownloadDiscordResource(ctx, *i.Url)
if err != nil {
var statusError DiscordResourceBadStatusCode
if errors.As(err, &statusError) {
@ -838,6 +838,7 @@ func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, in
}
// TODO(asaf): I believe this will also match https://example.com?hello=1&whatever=5
//
// Probably need to add word boundaries.
var REDiscordTag = regexp.MustCompile(`&([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)`)

View File

@ -346,6 +346,7 @@ type User struct {
Avatar *string `json:"avatar"`
IsBot bool `json:"bot"`
Locale string `json:"locale"`
Email string `json:"email"`
}
func UserFromMap(m interface{}, k string) *User {
@ -389,6 +390,7 @@ func GuildFromMap(m interface{}, k string) *Guild {
type GuildMember struct {
User *User `json:"user"`
Nick *string `json:"nick"`
Avatar *string `json:"avatar"`
// more fields not yet handled here
}
@ -411,6 +413,7 @@ func GuildMemberFromMap(m interface{}, k string) *GuildMember {
gm := &GuildMember{
User: UserFromMap(m, "user"),
Nick: maybeStringP(mmap, "nick"),
Avatar: maybeStringP(mmap, "avatar"),
}
return gm

View File

@ -110,7 +110,7 @@ func createLimiter(headers rateLimitHeaders, routeName string) {
buckets.Store(routeName, headers.Bucket)
ilimiter, loaded := rateLimiters.LoadOrStore(headers.Bucket, &restRateLimiter{
requests: make(chan struct{}, 100), // presumably this is big enough to handle bursts
requests: make(chan struct{}, 200), // presumably this is big enough to handle bursts
refills: make(chan rateLimiterRefill),
})
if !loaded {
@ -124,7 +124,9 @@ func createLimiter(headers rateLimitHeaders, routeName string) {
select {
case limiter.requests <- struct{}{}:
default:
log.Warn().Msg("rate limiting channel was too small; you should increase the default capacity")
log.Warn().
Int("remaining", headers.Remaining).
Msg("rate limiting channel was too small; you should increase the default capacity")
break prefillloop
}
}
@ -158,7 +160,9 @@ func createLimiter(headers rateLimitHeaders, routeName string) {
select {
case limiter.requests <- struct{}{}:
default:
log.Warn().Msg("rate limiting channel was too small; you should increase the default capacity")
log.Warn().
Int("maxRequests", refill.maxRequests).
Msg("rate limiting channel was too small; you should increase the default capacity")
break refillloop
}
}

View File

@ -652,11 +652,17 @@ func EditOriginalInteractionResponse(ctx context.Context, interactionToken strin
return &msg, nil
}
func GetAuthorizeUrl(state string) string {
func GetAuthorizeUrl(state string, includeEmail bool) string {
scope := "identify"
if includeEmail {
scope = "identify email"
}
params := make(url.Values)
params.Set("response_type", "code")
params.Set("client_id", config.Config.Discord.OAuthClientID)
params.Set("scope", "identify")
params.Set("scope", scope)
params.Set("prompt", "none") // immediately redirect back to HMN if already authorized
params.Set("state", state)
params.Set("redirect_uri", hmnurl.BuildDiscordOAuthCallback())
return fmt.Sprintf("%s?%s", buildUrl("/oauth2/authorize"), params.Encode())

View File

@ -121,6 +121,13 @@ func BuildLoginPage(redirectTo string) string {
return Url("/login", []Q{{Name: "redirect", Value: redirectTo}})
}
var RegexLoginWithDiscord = regexp.MustCompile("^/login-with-discord$")
func BuildLoginWithDiscord(redirectTo string) string {
defer CatchPanic()
return Url("/login-with-discord", []Q{{Name: "redirect", Value: redirectTo}})
}
var RegexLogoutAction = regexp.MustCompile("^/logout$")
func BuildLogoutAction(redir string) string {

View File

@ -0,0 +1,53 @@
package migrations
import (
"context"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"github.com/jackc/pgx/v5"
)
func init() {
registerMigration(AddPendingSignups{})
}
type AddPendingSignups struct{}
func (m AddPendingSignups) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2023, 5, 4, 2, 47, 12, 0, time.UTC))
}
func (m AddPendingSignups) Name() string {
return "AddPendingSignups"
}
func (m AddPendingSignups) Description() string {
return "Adds the pending login table"
}
func (m AddPendingSignups) Up(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx,
`
CREATE TABLE pending_login (
id VARCHAR(40) NOT NULL PRIMARY KEY,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
destination_url VARCHAR(999) NOT NULL
)
`,
)
if err != nil {
return err
}
return nil
}
func (m AddPendingSignups) Down(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `DROP TABLE pending_login`)
if err != nil {
return err
}
return nil
}

View File

@ -189,7 +189,7 @@ func seedUser(ctx context.Context, conn db.ConnOrTx, input models.User) *models.
$6, $7, $8, $9,
TRUE,
$10,
'2017-01-01T00:00:00Z', '192.168.2.1', null
'2017-01-01T00:00:00Z', '192.168.2.1', NULL
)
RETURNING $columns
`,

View File

@ -71,3 +71,9 @@ func (u *User) CanSeeUnpublishedEducationContent() bool {
func (u *User) CanAuthorEducation() bool {
return u.IsStaff || u.EducationRole == EduRoleAuthor
}
type PendingLogin struct {
ID string `db:"id"`
ExpiresAt time.Time `db:"expires_at"`
DestinationUrl string `db:"destination_url"`
}

View File

@ -130,33 +130,21 @@ header {
@include usevar(background-color, login-popup-background);
@include usevar(color, fg-font-color);
@extend .pa3;
border-width: 1px;
border-style: dashed;
@extend .b--dimmest;
visibility: hidden;
position: absolute;
z-index: 12;
margin-top: 10px;
right: 0px;
top: 20px;
width: 290px;
max-height: 0px;
overflow: hidden;
opacity: 0;
transition: all 0.2s;
right: 0;
top: 100%;
width: 100%;
&.open {
max-height: 170px;
opacity: 1;
visibility: visible;
}
label {
padding-right:10px;
@media #{$breakpoint-not-small} {
top: 2.2rem;
width: 17rem;
}
}
}

View File

@ -37,6 +37,18 @@
<div class="tc pa3">
Need an account? <a href="{{ .RegisterUrl }}">Sign up.</a>
</div>
<div class="mt3 tc">
<div class="b mb1">Third-party login</div>
<div class="flex flex-column g1">
<a href="{{ .LoginWithDiscordUrl }}" class="db br2 overflow-hidden flex" title="Log in with Discord">
<img
src="{{ static "discord-login.svg" }}"
alt="Log in with Discord"
>
</a>
</div>
</div>
</form>
</div>
</div>

View File

@ -13,14 +13,25 @@
{{ else }}
<a class="register pa2" id="register-link" href="{{ .Header.RegisterUrl }}">Register</a>
<a class="login pa2" id="login-link" href="{{ .LoginPageUrl }}">Log in</a>
<div id="login-popup">
<form action="{{ .Header.LoginActionUrl }}" method="post" class="ma0">
<div id="login-popup" class="pa3 bt bb ba-ns bw1 b--theme-dark">
<form action="{{ .Header.LoginActionUrl }}" method="post" class="ma0 flex flex-column">
<input type="text" name="username" class="w-100" value="" placeholder="Username" />
<input type="password" name="password" class="w-100 mt1" value="" placeholder="Password" />
<a class="db" style="padding: 0.5rem 0;" href="{{ .Header.ForgotPasswordUrl }}">Forgot your password?</a>
<a class="db mt1" href="{{ .Header.ForgotPasswordUrl }}">Forgot your password?</a>
<input type="hidden" name="redirect" value="{{ $.CurrentUrl }}">
<div class="pt2">
<input type="submit" value="Log In" />
<div class="mt2">
<input type="submit" value="Log In" class="w-100" />
</div>
<div class="mt3 tc">
<div class="b mb1">Third-party login</div>
<div class="flex flex-column g1">
<a href="{{ .Header.LoginWithDiscordUrl }}" class="db br2 overflow-hidden flex" title="Log in with Discord">
<img
src="{{ static "discord-login.svg" }}"
alt="Log in with Discord"
>
</a>
</div>
</div>
</form>
</div>
@ -166,7 +177,6 @@
const loginLink = document.getElementById("login-link");
if (loginPopup !== null) {
loginLink.onclick = (e) => {
e.preventDefault();
loginPopup.classList.toggle("open");

View File

@ -81,18 +81,20 @@
</div>
<div class="tab" data-name="Password" data-slug="password">
{{ if .HasPassword }}
<div class="edit-form-row">
<div class="pt-input-ns">Old password:</div>
<div>
<input id="id_old_password" name="old_password" type="password" />
</div>
</div>
{{ end }}
<div class="edit-form-row">
<div class="pt-input-ns">New password:</div>
<div>
<input name="new_password" type="password" />
<div class="c--dim f7 mw6">
Your password must be 8 or more characters, and must differ from your username and current password.
Your password must be 8 or more characters, and must differ from your username{{ if .HasPassword }} and current password{{ end }}.
Other than that, <a href="http://krebsonsecurity.com/password-dos-and-donts/" class="external" target="_blank">please follow best practices</a>.
</div>
</div>

View File

@ -45,6 +45,7 @@ type Header struct {
LogoutActionUrl string
ForgotPasswordUrl string
RegisterUrl string
LoginWithDiscordUrl string
HMNHomepageUrl string
ProjectIndexUrl string

View File

@ -104,3 +104,19 @@ func SleepContext(ctx context.Context, d time.Duration) error {
return nil
}
}
// Panics if the provided value is falsy (so, zero). This works for booleans
// but also normal values, through the magic of generics.
func Assert[T comparable](value T, msg ...any) {
var zero T
if value == zero {
finalMsg := ""
for i, arg := range msg {
if i > 0 {
finalMsg += " "
}
finalMsg += fmt.Sprintf("%v", arg)
}
panic(finalMsg)
}
}

View File

@ -11,6 +11,7 @@ import (
"git.handmade.network/hmn/hmn/src/auth"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/discord"
"git.handmade.network/hmn/hmn/src/email"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/logging"
@ -26,6 +27,7 @@ type LoginPageData struct {
RedirectUrl string
RegisterUrl string
ForgotPasswordUrl string
LoginWithDiscordUrl string
}
func LoginPage(c *RequestContext) ResponseData {
@ -41,6 +43,7 @@ func LoginPage(c *RequestContext) ResponseData {
RedirectUrl: redirect,
RegisterUrl: hmnurl.BuildRegister(redirect),
ForgotPasswordUrl: hmnurl.BuildRequestPasswordReset(),
LoginWithDiscordUrl: hmnurl.BuildLoginWithDiscord(redirect),
}, c.Perf)
return res
}
@ -120,6 +123,28 @@ func Login(c *RequestContext) ResponseData {
return res
}
func LoginWithDiscord(c *RequestContext) ResponseData {
destinationUrl := c.URL().Query().Get("redirect")
if c.CurrentUser != nil {
return c.Redirect(destinationUrl, http.StatusSeeOther)
}
pendingLogin, err := db.QueryOne[models.PendingLogin](c, c.Conn,
`
INSERT INTO pending_login (id, expires_at, destination_url)
VALUES ($1, $2, $3)
RETURNING $columns
`,
auth.MakeSessionId(), time.Now().Add(time.Minute*10), destinationUrl,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save pending login"))
}
discordAuthUrl := discord.GetAuthorizeUrl(pendingLogin.ID, true)
return c.Redirect(discordAuthUrl, http.StatusSeeOther)
}
func Logout(c *RequestContext) ResponseData {
redirect := c.Req.URL.Query().Get("redirect")
@ -460,6 +485,7 @@ func makeResponseForBadRegistrationTokenValidationResult(c *RequestContext, vali
}
// 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 {
if c.CurrentUser != nil {
@ -717,8 +743,12 @@ func tryLogin(c *RequestContext, user *models.User, password string) (bool, erro
defer c.Perf.EndBlock()
hashed, err := auth.ParsePasswordString(user.Password)
if err != nil {
if user.Password == "" {
return false, nil
} else {
return false, oops.New(err, "failed to parse password string")
}
}
passwordsMatch, err := auth.CheckPassword(password, hashed)
if err != nil {

View File

@ -69,6 +69,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
LogoutActionUrl: hmnurl.BuildLogoutAction(c.FullUrl()),
ForgotPasswordUrl: hmnurl.BuildRequestPasswordReset(),
RegisterUrl: hmnurl.BuildRegister(""),
LoginWithDiscordUrl: hmnurl.BuildLoginWithDiscord(c.FullUrl()),
HMNHomepageUrl: hmnurl.BuildHomepage(),
ProjectIndexUrl: hmnurl.BuildProjectIndex(1),

View File

@ -1,39 +1,115 @@
package website
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"git.handmade.network/hmn/hmn/src/assets"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/discord"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/utils"
"github.com/google/uuid"
)
// This callback handles Discord account linking whether the user is signed in
// or not. In all cases, the end state is that the user is signed into a
// Handmade Network account with a linked Discord account. HMN accounts will be
// created as necessary.
//
// If we initiate OAuth while logged in, we will use the current session's CSRF
// token as the OAuth state. Otherwise, we will generate a new entry in the
// pending_login table with an equivalently random token and use that token for
// the state.
//
// Considerations:
//
// | | Already signed in | Not signed in |
// |-----------------------|----------------------|-------------------------------|
// | No matching info | Link Discord account | Create HMN account |
// |-----------------------| to current HMN |-------------------------------|
// | Matching Discord user | account (stealing is | Log into HMN account and link |
// |-----------------------| ok, but make sure | Discord user to it. (Double- |
// | One matching email | any other accounts | check Discord link settings.) |
// |-----------------------| are unlinked) |-------------------------------|
// | More than one | | Fail login |
// | matching email | | |
// |-----------------------|----------------------|-------------------------------|
func DiscordOAuthCallback(c *RequestContext) ResponseData {
query := c.Req.URL.Query()
// Check the state
var destinationUrl string
tx, err := c.Conn.Begin(c)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to start transaction for Discord OAuth"))
}
defer tx.Rollback(c)
// Check the state, figure out where we're going
state := query.Get("state")
if state != c.CurrentSession.CSRFToken {
// CSRF'd!!!!
if c.CurrentUser == nil {
// Check the state against all our pending signins - if none is found,
// then CSRF'd!!!! (or the login just expired)
pendingLogin, err := db.QueryOne[models.PendingLogin](c, c.Conn,
`
SELECT $columns
FROM pending_login
WHERE
id = $1
AND expires_at > CURRENT_TIMESTAMP
`,
state,
)
if err == db.NotFound {
c.Logger.Warn().Str("userId", c.CurrentUser.Username).Msg("user failed Discord OAuth state validation - potential attack?")
res := c.Redirect("/", http.StatusSeeOther)
logoutUser(c, &res)
return res
} else if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up pending login"))
}
destinationUrl = pendingLogin.DestinationUrl
// Delete the pending login; we're done with it
_, err = tx.Exec(c, `DELETE FROM pending_login WHERE id = $1`, pendingLogin.ID)
if err != nil {
c.Logger.Warn().Str("id", pendingLogin.ID).Err(err).Msg("failed to delete pending login")
}
} else {
// Check the state against the current session - if it does not match,
// then CSRF'd!!!!
if state != c.CurrentSession.CSRFToken {
c.Logger.Warn().Str("userId", c.CurrentUser.Username).Msg("user failed Discord OAuth state validation - potential attack?")
res := c.Redirect("/", http.StatusSeeOther)
logoutUser(c, &res)
return res
}
// The only way into OAuth when logged in is when linking your Discord
// account in settings.
destinationUrl = hmnurl.BuildUserSettings("discord")
}
// Check for error values and redirect back to user settings
// Check for error values and redirect back to from whence they came
if errCode := query.Get("error"); errCode != "" {
if errCode == "access_denied" {
// This occurs when the user cancels. Just go back to the profile page.
return c.Redirect(hmnurl.BuildUserSettings("discord"), http.StatusSeeOther)
// This occurs when the user cancels. Just go back so they can try again.
var dest string
if c.CurrentUser == nil {
// Send 'em back to the login page for another go, with the
// same destination
dest = hmnurl.BuildLoginPage(destinationUrl)
} else {
dest = hmnurl.BuildUserSettings("discord")
}
return c.Redirect(dest, http.StatusSeeOther)
} else {
return c.RejectRequest("Failed to authenticate with Discord.")
}
@ -41,60 +117,198 @@ func DiscordOAuthCallback(c *RequestContext) ResponseData {
// Do the actual token exchange
code := query.Get("code")
res, err := discord.ExchangeOAuthCode(c, code, hmnurl.BuildDiscordOAuthCallback())
authRes, err := discord.ExchangeOAuthCode(c, code, hmnurl.BuildDiscordOAuthCallback())
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to exchange Discord authorization code"))
}
expiry := time.Now().Add(time.Duration(res.ExpiresIn) * time.Second)
expiry := time.Now().Add(time.Duration(authRes.ExpiresIn) * time.Second)
user, err := discord.GetCurrentUserAsOAuth(c, res.AccessToken)
user, err := discord.GetCurrentUserAsOAuth(c, authRes.AccessToken)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch Discord user info"))
}
// Add the role on Discord
err = discord.AddGuildMemberRole(c, user.ID, config.Config.Discord.MemberRoleID)
hmnMember, err := discord.GetGuildMember(c, config.Config.Discord.GuildID, user.ID)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to add member role"))
if err == discord.NotFound {
// nothing, this is fine
} else {
c.Logger.Error().Err(err).Msg("failed to get HMN Discord member for Discord user")
}
}
// Add the user to our database
_, err = c.Conn.Exec(c,
// Make the necessary updates in our database (see table above)
// Determine which HMN user to associate this Discord login with. This
// may not turn anything up, in which case we need to make an account.
var hmnUser *models.User
if c.CurrentUser != nil {
hmnUser = c.CurrentUser
} else {
utils.Assert(user.Email, "didn't get an email from Discord! bad scopes?")
userFromDiscordID, err := db.QueryOne[models.User](c, tx,
`
INSERT INTO discord_user (username, discriminator, access_token, refresh_token, avatar, locale, userid, expiry, hmn_user_id)
SELECT $columns{hmn_user}
FROM
discord_user
JOIN hmn_user ON discord_user.hmn_user_id = hmn_user.id
WHERE userid = $1
`,
user.ID,
)
if err == nil {
hmnUser = userFromDiscordID
} else if err == db.NotFound {
// no problem
} else {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up existing HMN user from Discord OAuth"))
}
if hmnUser == nil {
usersFromDiscordEmail, err := db.Query[models.User](c, tx,
`
SELECT $columns
FROM hmn_user
WHERE
LOWER(email) = LOWER($1)
`,
user.Email,
)
if err == nil {
if len(usersFromDiscordEmail) > 1 {
// oh no why don't we have a unique constraint on emails
return c.RejectRequest("There are multiple Handmade Network accounts with this email address. Please sign into one of them separately.")
} else if len(usersFromDiscordEmail) == 1 {
hmnUser = usersFromDiscordEmail[0]
}
} else if err == db.NotFound {
// no problem
} else {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up existing HMN user by email"))
}
}
}
// Create a new HMN account if no existing account matches
if hmnUser == nil {
// Check if an HMN account already has this username. We don't link
// in this case because usernames can be changed and we don't want
// account takeovers.
usernameTaken, err := db.QueryOneScalar[bool](c, tx,
`SELECT COUNT(*) > 0 FROM hmn_user WHERE LOWER(username) = LOWER($1)`,
user.Username,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check if username was taken when logging in with Discord"))
}
if usernameTaken {
return c.RejectRequest(fmt.Sprintf("There is already a Handmade Network account with the username \"%s\".", user.Username))
}
var displayName string
if hmnMember != nil && hmnMember.Nick != nil {
displayName = *hmnMember.Nick
}
var avatarHash *string
if hmnMember != nil && hmnMember.Avatar != nil {
avatarHash = hmnMember.Avatar
} else if user.Avatar != nil {
avatarHash = user.Avatar
}
var avatarAssetID *uuid.UUID
if avatarHash != nil {
// Note! Not using the transaction here. Don't want to fail the login due to avatars.
if avatarAsset, err := saveDiscordAvatar(c, c.Conn, user.ID, *user.Avatar); err == nil {
avatarAssetID = &avatarAsset.ID
} else {
c.Logger.Warn().Err(err).Msg("failed to save Discord avatar")
}
}
newHMNUser, err := db.QueryOne[models.User](c, tx,
`
INSERT INTO hmn_user (
username, name, email, password, avatar_asset_id, date_joined, registration_ip
) VALUES (
$1, $2, $3, '', $4, $5, $6
)
RETURNING $columns
`,
user.Username, displayName, strings.ToLower(user.Email), avatarAssetID, time.Now(), c.GetIP(),
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create new HMN user for Discord login"))
}
hmnUser = newHMNUser
}
// Add the Discord user data to our database
_, err = tx.Exec(c,
`
INSERT INTO
discord_user (username, discriminator, access_token, refresh_token, avatar, locale, userid, expiry, hmn_user_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (userid) DO UPDATE SET
username = EXCLUDED.username,
discriminator = EXCLUDED.discriminator,
access_token = EXCLUDED.access_token,
refresh_token = EXCLUDED.refresh_token,
avatar = EXCLUDED.avatar,
locale = EXCLUDED.locale,
expiry = EXCLUDED.expiry,
hmn_user_id = EXCLUDED.hmn_user_id
`,
user.Username,
user.Discriminator,
res.AccessToken,
res.RefreshToken,
authRes.AccessToken,
authRes.RefreshToken,
user.Avatar,
user.Locale,
user.ID,
expiry,
c.CurrentUser.ID,
hmnUser.ID,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save new Discord user info"))
}
if c.CurrentUser.Status == models.UserStatusConfirmed {
_, err = c.Conn.Exec(c,
// Mark the HMN user as confirmed - Discord is good enough auth for us
_, err = tx.Exec(c,
`
UPDATE hmn_user
SET status = $1
WHERE id = $2
`,
models.UserStatusApproved,
c.CurrentUser.ID,
hmnUser.ID,
)
if err != nil {
c.Logger.Error().Err(err).Msg("failed to set user status to approved after linking discord account")
// NOTE(asaf): It's not worth failing the request over this, so we're not returning an error to the user.
}
err = tx.Commit(c)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save updates from Discord OAuth"))
}
return c.Redirect(hmnurl.BuildUserSettings("discord"), http.StatusSeeOther)
// Add the role on Discord
if hmnMember != nil {
err = discord.AddGuildMemberRole(c, user.ID, config.Config.Discord.MemberRoleID)
if err != nil {
c.Logger.Error().Err(err).Msg("failed to add member role")
}
}
res := c.Redirect(destinationUrl, http.StatusSeeOther)
err = loginUser(c, hmnUser, &res)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err)
}
return res
}
func DiscordUnlink(c *RequestContext) ResponseData {
@ -188,3 +402,29 @@ func DiscordShowcaseBacklog(c *RequestContext) ResponseData {
return c.Redirect(hmnurl.BuildUserProfile(c.CurrentUser.Username), http.StatusSeeOther)
}
func saveDiscordAvatar(ctx context.Context, conn db.ConnOrTx, userID, avatarHash string) (*models.Asset, error) {
const size = 256
filename := fmt.Sprintf("%s.png", avatarHash)
url := fmt.Sprintf("https://cdn.discordapp.com/avatars/%s/%s?size=%d", userID, filename, size)
content, _, err := discord.DownloadDiscordResource(ctx, url)
if err != nil {
return nil, oops.New(err, "failed to download Discord avatar")
}
asset, err := assets.Create(ctx, conn, assets.CreateInput{
Content: content,
Filename: filename,
ContentType: "image/png",
Width: size,
Height: size,
})
if err != nil {
return nil, oops.New(err, "failed to save asset for Discord attachment")
}
return asset, nil
}

View File

@ -75,6 +75,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
hmnOnly.POST(hmnurl.RegexLoginAction, securityTimerMiddleware(time.Millisecond*100, Login))
hmnOnly.GET(hmnurl.RegexLogoutAction, Logout)
hmnOnly.GET(hmnurl.RegexLoginPage, LoginPage)
hmnOnly.GET(hmnurl.RegexLoginWithDiscord, LoginWithDiscord)
hmnOnly.GET(hmnurl.RegexRegister, RegisterNewUser)
hmnOnly.POST(hmnurl.RegexRegister, securityTimerMiddleware(email.ExpectedEmailSendDuration, RegisterNewUserSubmit))
@ -104,7 +105,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
hmnOnly.GET(hmnurl.RegexProjectNew, needsAuth(ProjectNew))
hmnOnly.POST(hmnurl.RegexProjectNew, needsAuth(csrfMiddleware(ProjectNewSubmit)))
hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, needsAuth(DiscordOAuthCallback))
hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, DiscordOAuthCallback)
hmnOnly.POST(hmnurl.RegexDiscordUnlink, needsAuth(csrfMiddleware(DiscordUnlink)))
hmnOnly.POST(hmnurl.RegexDiscordShowcaseBacklog, needsAuth(csrfMiddleware(DiscordShowcaseBacklog)))

View File

@ -217,6 +217,7 @@ func UserSettings(c *RequestContext) ResponseData {
Email string // these fields are handled specially on templates.User
ShowEmail bool
LinksText string
HasPassword bool
SubmitUrl string
ContactUrl string
@ -292,13 +293,14 @@ func UserSettings(c *RequestContext) ResponseData {
Email: c.CurrentUser.Email,
ShowEmail: c.CurrentUser.ShowEmail,
LinksText: linksText,
HasPassword: c.CurrentUser.Password != "",
SubmitUrl: hmnurl.BuildUserSettings(""),
ContactUrl: hmnurl.BuildContactPage(),
DiscordUser: tduser,
DiscordNumUnsavedMessages: numUnsavedMessages,
DiscordAuthorizeUrl: discord.GetAuthorizeUrl(c.CurrentSession.CSRFToken),
DiscordAuthorizeUrl: discord.GetAuthorizeUrl(c.CurrentSession.CSRFToken, false),
DiscordUnlinkUrl: hmnurl.BuildDiscordUnlink(),
DiscordShowcaseBacklogUrl: hmnurl.BuildDiscordShowcaseBacklog(),
}, c.Perf)
@ -424,7 +426,13 @@ func UserSettingsSave(c *RequestContext) ResponseData {
// Update password
oldPassword := form.Get("old_password")
newPassword := form.Get("new_password")
if oldPassword != "" && newPassword != "" {
var doChangePassword bool
if c.CurrentUser.Password == "" {
doChangePassword = newPassword != ""
} else {
doChangePassword = oldPassword != "" && newPassword != ""
}
if doChangePassword {
errorRes := updatePassword(c, tx, oldPassword, newPassword)
if errorRes != nil {
return *errorRes
@ -558,6 +566,7 @@ func UserProfileAdminNuke(c *RequestContext) ResponseData {
}
func updatePassword(c *RequestContext, tx pgx.Tx, old, new string) *ResponseData {
if c.CurrentUser.Password != "" {
oldHashedPassword, err := auth.ParsePasswordString(c.CurrentUser.Password)
if err != nil {
c.Logger.Warn().Err(err).Msg("failed to parse user's password string")
@ -574,9 +583,10 @@ func updatePassword(c *RequestContext, tx pgx.Tx, old, new string) *ResponseData
res := c.RejectRequest("The old password you provided was not correct.")
return &res
}
}
newHashedPassword := auth.HashPassword(new)
err = auth.UpdatePassword(c, tx, c.CurrentUser.Username, newHashedPassword)
err := auth.UpdatePassword(c, tx, c.CurrentUser.Username, newHashedPassword)
if err != nil {
res := c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update password"))
return &res

View File

@ -43,7 +43,7 @@ var WebsiteCommand = &cobra.Command{
}
backgroundJobsDone := jobs.Zip(
auth.PeriodicallyDeleteExpiredSessions(backgroundJobContext, conn),
auth.PeriodicallyDeleteExpiredStuff(backgroundJobContext, conn),
auth.PeriodicallyDeleteInactiveUsers(backgroundJobContext, conn),
perfCollector.Job,
discord.RunDiscordBot(backgroundJobContext, conn),