vikunja/pkg/webtests/huma_auth_login_test.go

197 lines
7.2 KiB
Go

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package webtests
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth"
"code.vikunja.io/api/pkg/user"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// refreshCookie returns the Set-Cookie value for the refresh-token cookie, or ""
// if the response set no such cookie.
func refreshCookie(rec *httptest.ResponseRecorder) *http.Cookie {
for _, c := range rec.Result().Cookies() {
if c.Name == auth.RefreshTokenCookieName {
return c
}
}
return nil
}
// TestHumaLogin ports the v1 login coverage to /api/v2: it asserts the token
// response, the HttpOnly refresh cookie, the no-store header, and the credential
// and TOTP gates.
func TestHumaLogin(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
login := func(body string) *httptest.ResponseRecorder {
return humaRequest(t, e, http.MethodPost, "/api/v2/login", body, "", "")
}
t.Run("normal login", func(t *testing.T) {
rec := login(`{"username":"user1","password":"12345678"}`)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), `"token":"`)
assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
cookie := refreshCookie(rec)
require.NotNil(t, cookie, "login must set the refresh-token cookie")
assert.NotEmpty(t, cookie.Value)
assert.True(t, cookie.HttpOnly, "refresh cookie must be HttpOnly")
})
t.Run("wrong password", func(t *testing.T) {
rec := login(`{"username":"user1","password":"wrong"}`)
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.Equal(t, user.ErrCodeWrongUsernameOrPassword, problemCode(t, rec))
assert.Nil(t, refreshCookie(rec), "a failed login must not set a refresh cookie")
})
t.Run("nonexistent user", func(t *testing.T) {
rec := login(`{"username":"userWhichDoesNotExist","password":"12345678"}`)
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.Equal(t, user.ErrCodeWrongUsernameOrPassword, problemCode(t, rec))
})
t.Run("unconfirmed email", func(t *testing.T) {
rec := login(`{"username":"user5","password":"12345678"}`)
assert.Equal(t, http.StatusPreconditionFailed, rec.Code)
assert.Equal(t, user.ErrCodeEmailNotConfirmed, problemCode(t, rec))
})
t.Run("disabled account", func(t *testing.T) {
rec := login(`{"username":"user17","password":"12345678"}`)
assert.Equal(t, http.StatusPreconditionFailed, rec.Code)
assert.Equal(t, user.ErrCodeAccountDisabled, problemCode(t, rec))
})
t.Run("locked account", func(t *testing.T) {
rec := login(`{"username":"user18","password":"12345678"}`)
assert.Equal(t, http.StatusPreconditionFailed, rec.Code)
assert.Equal(t, user.ErrCodeAccountLocked, problemCode(t, rec))
})
t.Run("TOTP required but missing", func(t *testing.T) {
rec := login(`{"username":"user10","password":"12345678"}`)
assert.Equal(t, http.StatusPreconditionFailed, rec.Code)
assert.Equal(t, user.ErrCodeInvalidTOTPPasscode, problemCode(t, rec))
})
t.Run("TOTP wrong", func(t *testing.T) {
rec := login(`{"username":"user10","password":"12345678","totp_passcode":"000000"}`)
assert.Equal(t, http.StatusPreconditionFailed, rec.Code)
assert.Equal(t, user.ErrCodeInvalidTOTPPasscode, problemCode(t, rec))
})
t.Run("TOTP correct", func(t *testing.T) {
code, err := totp.GenerateCode("JBSWY3DPEHPK3PXP", time.Now())
require.NoError(t, err)
rec := login(`{"username":"user10","password":"12345678","totp_passcode":"` + code + `"}`)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), `"token":"`)
assert.NotNil(t, refreshCookie(rec))
})
}
// TestHumaLogout proves the v2 logout deletes the session server-side and clears
// the refresh-token cookie.
func TestHumaLogout(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
// Create a session so logout has something to delete, then mint a JWT whose
// sid claim points at it.
s := db.NewSession()
session, err := models.CreateSession(s, testuser1.ID, "test", "127.0.0.1", false, nil)
require.NoError(t, err)
require.NoError(t, s.Commit())
require.NoError(t, s.Close())
token, err := auth.NewUserJWTAuthtoken(&testuser1, session.ID)
require.NoError(t, err)
rec := humaRequest(t, e, http.MethodPost, "/api/v2/logout", "", token, "")
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), "Successfully logged out.")
cookie := refreshCookie(rec)
require.NotNil(t, cookie, "logout must clear the refresh cookie")
assert.Empty(t, cookie.Value, "cleared cookie has no value")
assert.Negative(t, cookie.MaxAge, "cleared cookie is expired")
// The session must be gone.
check := db.NewSession()
defer check.Close()
exists, err := check.Where("id = ?", session.ID).Exist(&models.Session{})
require.NoError(t, err)
assert.False(t, exists, "logout must delete the session")
}
// TestHumaLoginUnauthenticated proves login needs no token (it is a public op).
func TestHumaLoginUnauthenticated(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
rec := humaRequest(t, e, http.MethodPost, "/api/v2/login", `{"username":"user1","password":"12345678"}`, "", "")
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
}
// TestHumaOpenIDGating proves the OIDC callback route only exists when OpenID is
// enabled, mirroring the registrar gate.
func TestHumaOpenIDGating(t *testing.T) {
body := `{"code":"abc","redirect_url":"https://example.com"}`
t.Run("disabled returns 404", func(t *testing.T) {
config.AuthOpenIDEnabled.Set(false)
e, err := setupTestEnv()
require.NoError(t, err)
rec := humaRequest(t, e, http.MethodPost, "/api/v2/auth/openid/test/callback", body, "", "")
assert.Equal(t, http.StatusNotFound, rec.Code)
})
t.Run("enabled does not require auth", func(t *testing.T) {
config.AuthOpenIDEnabled.Set(true)
defer config.AuthOpenIDEnabled.Set(false)
e, err := setupTestEnv()
require.NoError(t, err)
// No provider is configured, so the call fails downstream — but it must
// not 404 as an unknown route nor 401 for missing auth, which proves the
// public route is registered.
rec := humaRequest(t, e, http.MethodPost, "/api/v2/auth/openid/doesnotexist/callback", body, "", "")
assert.NotEqual(t, http.StatusNotFound, rec.Code)
assert.NotEqual(t, http.StatusUnauthorized, rec.Code)
})
}