From 8dcb4be8a899bc38ff129c2d2d74417ce4c200bd Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Wed, 7 Dec 2022 20:44:20 -0500 Subject: [PATCH] Token login --- auth/auth.go | 5 ++-- auth/auth_sqlite.go | 16 +++++++++-- server/server.go | 41 +++++++++++++++++++++++------ web/src/app/Api.js | 18 ++++++++++--- web/src/app/Session.js | 4 +++ web/src/app/utils.js | 2 +- web/src/components/ActionBar.js | 13 ++++----- web/src/components/Login.js | 2 +- web/src/components/Notifications.js | 5 +++- web/src/components/Preferences.js | 13 +++++++-- web/src/components/routes.js | 1 + 11 files changed, 94 insertions(+), 26 deletions(-) diff --git a/auth/auth.go b/auth/auth.go index 93a0ecf4..2dde3858 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -14,8 +14,8 @@ type Auther interface { Authenticate(username, password string) (*User, error) AuthenticateToken(token string) (*User, error) - - GenerateToken(user *User) (string, error) + CreateToken(user *User) (string, error) + RemoveToken(user *User) error // Authorize returns nil if the given user has access to the given topic using the desired // permission. The user param may be nil to signal an anonymous user. @@ -62,6 +62,7 @@ type Manager interface { type User struct { Name string Hash string // password hash (bcrypt) + Token string // Only set if token was used to log in Role Role Grants []Grant Language string diff --git a/auth/auth_sqlite.go b/auth/auth_sqlite.go index 536d9e30..d61d176b 100644 --- a/auth/auth_sqlite.go +++ b/auth/auth_sqlite.go @@ -102,6 +102,7 @@ const ( deleteTopicAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) AND topic = ?` insertTokenQuery = `INSERT INTO user_token (user_id, token, expires) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?)` + deleteTokenQuery = `DELETE FROM user_token WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?` ) // Schema management queries @@ -138,7 +139,7 @@ func NewSQLiteAuth(filename string, defaultRead, defaultWrite bool) (*SQLiteAuth }, nil } -// AuthenticateUser checks username and password and returns a user if correct. The method +// Authenticate checks username and password and returns a user if correct. The method // returns in constant-ish time, regardless of whether the user exists or the password is // correct or incorrect. func (a *SQLiteAuth) Authenticate(username, password string) (*User, error) { @@ -162,10 +163,11 @@ func (a *SQLiteAuth) AuthenticateToken(token string) (*User, error) { if err != nil { return nil, ErrUnauthenticated } + user.Token = token return user, nil } -func (a *SQLiteAuth) GenerateToken(user *User) (string, error) { +func (a *SQLiteAuth) CreateToken(user *User) (string, error) { token := util.RandomString(tokenLength) expires := 1 // FIXME if _, err := a.db.Exec(insertTokenQuery, user.Name, token, expires); err != nil { @@ -174,6 +176,16 @@ func (a *SQLiteAuth) GenerateToken(user *User) (string, error) { return token, nil } +func (a *SQLiteAuth) RemoveToken(user *User) error { + if user.Token == "" { + return ErrUnauthorized + } + if _, err := a.db.Exec(deleteTokenQuery, user.Name, user.Token); err != nil { + return err + } + return nil +} + // Authorize returns nil if the given user has access to the given topic using the desired // permission. The user param may be nil to signal an anonymous user. func (a *SQLiteAuth) Authorize(user *User, topic string, perm Permission) error { diff --git a/server/server.go b/server/server.go index 75f49d6f..23ef010a 100644 --- a/server/server.go +++ b/server/server.go @@ -34,6 +34,17 @@ import ( "heckel.io/ntfy/util" ) +/* + TODO + expire tokens + auto-refresh tokens from UI + pricing page + home page + + + +*/ + // Server is the main server, providing the UI and API for ntfy type Server struct { config *Config @@ -71,7 +82,7 @@ var ( webConfigPath = "/config.js" userStatsPath = "/user/stats" // FIXME get rid of this in favor of /user/account - userAuthPath = "/user/auth" + userTokenPath = "/user/token" userAccountPath = "/user/account" matrixPushPath = "/_matrix/push/v1/notify" staticRegex = regexp.MustCompile(`^/static/.+`) @@ -306,8 +317,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureWebEnabled(s.handleWebConfig)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == userStatsPath { return s.handleUserStats(w, r, v) - } else if r.Method == http.MethodGet && r.URL.Path == userAuthPath { - return s.handleUserAuth(w, r, v) + } else if r.Method == http.MethodGet && r.URL.Path == userTokenPath { + return s.handleUserTokenCreate(w, r, v) + } else if r.Method == http.MethodDelete && r.URL.Path == userTokenPath { + return s.handleUserTokenDelete(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == userAccountPath { return s.handleUserAccount(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath { @@ -408,16 +421,16 @@ type tokenAuthResponse struct { Token string `json:"token"` } -func (s *Server) handleUserAuth(w http.ResponseWriter, r *http.Request, v *visitor) error { +func (s *Server) handleUserTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { // TODO rate limit if v.user == nil { return errHTTPUnauthorized } - token, err := s.auth.GenerateToken(v.user) + token, err := s.auth.CreateToken(v.user) if err != nil { return err } - w.Header().Set("Content-Type", "text/json") + w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this response := &tokenAuthResponse{ Token: token, @@ -428,6 +441,18 @@ func (s *Server) handleUserAuth(w http.ResponseWriter, r *http.Request, v *visit return nil } +func (s *Server) handleUserTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { + // TODO rate limit + if v.user == nil || v.user.Token == "" { + return errHTTPUnauthorized + } + if err := s.auth.RemoveToken(v.user); err != nil { + return err + } + w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this + return nil +} + type userSubscriptionResponse struct { BaseURL string `json:"base_url"` Topic string `json:"topic"` @@ -454,7 +479,7 @@ type userAccountResponse struct { } func (s *Server) handleUserAccount(w http.ResponseWriter, r *http.Request, v *visitor) error { - w.Header().Set("Content-Type", "text/json") + w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this response := &userAccountResponse{} if v.user != nil { @@ -1136,7 +1161,7 @@ func parseSince(r *http.Request, poll bool) (sinceMarker, error) { } func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request, _ *visitor) error { - w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST") + w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE") w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests w.Header().Set("Access-Control-Allow-Headers", "*") // CORS, allow auth via JS // FIXME is this terrible? return nil diff --git a/web/src/app/Api.js b/web/src/app/Api.js index c106a280..6f692a6a 100644 --- a/web/src/app/Api.js +++ b/web/src/app/Api.js @@ -7,7 +7,7 @@ import { topicUrlJsonPoll, topicUrlJsonPollWithSince, userAccountUrl, - userAuthUrl, + userTokenUrl, userStatsUrl } from "./utils"; import userManager from "./UserManager"; @@ -119,8 +119,8 @@ class Api { throw new Error(`Unexpected server response ${response.status}`); } - async userAuth(baseUrl, user) { - const url = userAuthUrl(baseUrl); + async login(baseUrl, user) { + const url = userTokenUrl(baseUrl); console.log(`[Api] Checking auth for ${url}`); const response = await fetch(url, { headers: maybeWithBasicAuth({}, user) @@ -135,6 +135,18 @@ class Api { return json.token; } + async logout(baseUrl, token) { + const url = userTokenUrl(baseUrl); + console.log(`[Api] Logging out from ${url} using token ${token}`); + const response = await fetch(url, { + method: "DELETE", + headers: maybeWithBearerAuth({}, token) + }); + if (response.status !== 200) { + throw new Error(`Unexpected server response ${response.status}`); + } + } + async userStats(baseUrl) { const url = userStatsUrl(baseUrl); console.log(`[Api] Fetching user stats ${url}`); diff --git a/web/src/app/Session.js b/web/src/app/Session.js index 1ae8606a..06b5f8f7 100644 --- a/web/src/app/Session.js +++ b/web/src/app/Session.js @@ -9,6 +9,10 @@ class Session { localStorage.removeItem("token"); } + exists() { + return this.username() && this.token(); + } + username() { return localStorage.getItem("user"); } diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 36184090..d72c4c3f 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -19,7 +19,7 @@ export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJ export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`; export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic)); export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`; -export const userAuthUrl = (baseUrl) => `${baseUrl}/user/auth`; +export const userTokenUrl = (baseUrl) => `${baseUrl}/user/token`; export const userAccountUrl = (baseUrl) => `${baseUrl}/user/account`; export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js index 5d4b5847..ffd08751 100644 --- a/web/src/components/ActionBar.js +++ b/web/src/components/ActionBar.js @@ -246,7 +246,7 @@ const ProfileIcon = (props) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); const anchorRef = useRef(null); - const username = session.username(); + const navigate = useNavigate(); const handleToggleOpen = () => { setOpen((prevOpen) => !prevOpen); @@ -272,7 +272,8 @@ const ProfileIcon = (props) => { // TODO }; - const handleLogout = () => { + const handleLogout = async () => { + await api.logout("http://localhost:2586"/*window.location.origin*/, session.token()); session.reset(); window.location.href = routes.app; }; @@ -288,15 +289,15 @@ const ProfileIcon = (props) => { return ( <> - {username && + {session.exists() && } - {!username && + {!session.exists() && <> - - + + } { username: data.get('email'), password: data.get('password'), } - const token = await api.userAuth("http://localhost:2586"/*window.location.origin*/, user); + const token = await api.login("http://localhost:2586"/*window.location.origin*/, user); console.log(`[Api] User auth for user ${user.username} successful, token is ${token}`); session.store(user.username, token); window.location.href = routes.app; diff --git a/web/src/components/Notifications.js b/web/src/components/Notifications.js index 9a4baf7c..b4804022 100644 --- a/web/src/components/Notifications.js +++ b/web/src/components/Notifications.js @@ -84,7 +84,10 @@ const NotificationList = (props) => { useEffect(() => { return () => { setMaxCount(pageSize); - document.getElementById("main").scrollTo(0, 0); + const main = document.getElementById("main"); + if (main) { + main.scrollTo(0, 0); + } } }, [props.id]); diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js index f23a053d..f5c23f93 100644 --- a/web/src/components/Preferences.js +++ b/web/src/components/Preferences.js @@ -441,6 +441,11 @@ const Language = () => { const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" "); const lang = i18n.language ?? "en"; + const handleChange = async (ev) => { + await i18n.changeLanguage(ev.target.value); + //api.update + }; + // Remember: Flags are not languages. Don't put flags next to the language in the list. // Languages names from: https://www.omniglot.com/language/names.htm // Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l @@ -448,7 +453,7 @@ const Language = () => { return ( - English Bahasa Indonesia Български @@ -474,6 +479,10 @@ const Language = () => { ) }; +const AccessControl = () => { + return <>; +} +/* const AccessControl = () => { const { t } = useTranslation(); const [dialogKey, setDialogKey] = useState(0); @@ -632,6 +641,6 @@ const AccessControlDialog = (props) => { ); }; - +*/ export default Preferences; diff --git a/web/src/components/routes.js b/web/src/components/routes.js index 299f5285..27b36736 100644 --- a/web/src/components/routes.js +++ b/web/src/components/routes.js @@ -4,6 +4,7 @@ import {shortUrl} from "../app/utils"; const routes = { home: "/", login: "/login", + signup: "/signup", app: config.appRoot, settings: "/settings", subscription: "/:topic",