Token login

This commit is contained in:
Philipp Heckel 2022-12-07 20:44:20 -05:00
parent 35657a7bbd
commit 8dcb4be8a8
11 changed files with 94 additions and 26 deletions

View file

@ -14,8 +14,8 @@ type Auther interface {
Authenticate(username, password string) (*User, error) Authenticate(username, password string) (*User, error)
AuthenticateToken(token string) (*User, error) AuthenticateToken(token string) (*User, error)
CreateToken(user *User) (string, error)
GenerateToken(user *User) (string, error) RemoveToken(user *User) error
// Authorize returns nil if the given user has access to the given topic using the desired // 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. // permission. The user param may be nil to signal an anonymous user.
@ -62,6 +62,7 @@ type Manager interface {
type User struct { type User struct {
Name string Name string
Hash string // password hash (bcrypt) Hash string // password hash (bcrypt)
Token string // Only set if token was used to log in
Role Role Role Role
Grants []Grant Grants []Grant
Language string Language string

View file

@ -102,6 +102,7 @@ const (
deleteTopicAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) AND topic = ?` 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 = ?), ?, ?)` 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 // Schema management queries
@ -138,7 +139,7 @@ func NewSQLiteAuth(filename string, defaultRead, defaultWrite bool) (*SQLiteAuth
}, nil }, 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 // returns in constant-ish time, regardless of whether the user exists or the password is
// correct or incorrect. // correct or incorrect.
func (a *SQLiteAuth) Authenticate(username, password string) (*User, error) { func (a *SQLiteAuth) Authenticate(username, password string) (*User, error) {
@ -162,10 +163,11 @@ func (a *SQLiteAuth) AuthenticateToken(token string) (*User, error) {
if err != nil { if err != nil {
return nil, ErrUnauthenticated return nil, ErrUnauthenticated
} }
user.Token = token
return user, nil return user, nil
} }
func (a *SQLiteAuth) GenerateToken(user *User) (string, error) { func (a *SQLiteAuth) CreateToken(user *User) (string, error) {
token := util.RandomString(tokenLength) token := util.RandomString(tokenLength)
expires := 1 // FIXME expires := 1 // FIXME
if _, err := a.db.Exec(insertTokenQuery, user.Name, token, expires); err != nil { 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 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 // 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. // permission. The user param may be nil to signal an anonymous user.
func (a *SQLiteAuth) Authorize(user *User, topic string, perm Permission) error { func (a *SQLiteAuth) Authorize(user *User, topic string, perm Permission) error {

View file

@ -34,6 +34,17 @@ import (
"heckel.io/ntfy/util" "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 // Server is the main server, providing the UI and API for ntfy
type Server struct { type Server struct {
config *Config config *Config
@ -71,7 +82,7 @@ var (
webConfigPath = "/config.js" webConfigPath = "/config.js"
userStatsPath = "/user/stats" // FIXME get rid of this in favor of /user/account userStatsPath = "/user/stats" // FIXME get rid of this in favor of /user/account
userAuthPath = "/user/auth" userTokenPath = "/user/token"
userAccountPath = "/user/account" userAccountPath = "/user/account"
matrixPushPath = "/_matrix/push/v1/notify" matrixPushPath = "/_matrix/push/v1/notify"
staticRegex = regexp.MustCompile(`^/static/.+`) 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) return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath { } else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
return s.handleUserStats(w, r, v) return s.handleUserStats(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == userAuthPath { } else if r.Method == http.MethodGet && r.URL.Path == userTokenPath {
return s.handleUserAuth(w, r, v) 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 { } else if r.Method == http.MethodGet && r.URL.Path == userAccountPath {
return s.handleUserAccount(w, r, v) return s.handleUserAccount(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath { } else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
@ -408,16 +421,16 @@ type tokenAuthResponse struct {
Token string `json:"token"` 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 // TODO rate limit
if v.user == nil { if v.user == nil {
return errHTTPUnauthorized return errHTTPUnauthorized
} }
token, err := s.auth.GenerateToken(v.user) token, err := s.auth.CreateToken(v.user)
if err != nil { if err != nil {
return err 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 w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
response := &tokenAuthResponse{ response := &tokenAuthResponse{
Token: token, Token: token,
@ -428,6 +441,18 @@ func (s *Server) handleUserAuth(w http.ResponseWriter, r *http.Request, v *visit
return nil 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 { type userSubscriptionResponse struct {
BaseURL string `json:"base_url"` BaseURL string `json:"base_url"`
Topic string `json:"topic"` Topic string `json:"topic"`
@ -454,7 +479,7 @@ type userAccountResponse struct {
} }
func (s *Server) handleUserAccount(w http.ResponseWriter, r *http.Request, v *visitor) error { 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 w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
response := &userAccountResponse{} response := &userAccountResponse{}
if v.user != nil { 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 { 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-Origin", "*") // CORS, allow cross-origin requests
w.Header().Set("Access-Control-Allow-Headers", "*") // CORS, allow auth via JS // FIXME is this terrible? w.Header().Set("Access-Control-Allow-Headers", "*") // CORS, allow auth via JS // FIXME is this terrible?
return nil return nil

View file

@ -7,7 +7,7 @@ import {
topicUrlJsonPoll, topicUrlJsonPoll,
topicUrlJsonPollWithSince, topicUrlJsonPollWithSince,
userAccountUrl, userAccountUrl,
userAuthUrl, userTokenUrl,
userStatsUrl userStatsUrl
} from "./utils"; } from "./utils";
import userManager from "./UserManager"; import userManager from "./UserManager";
@ -119,8 +119,8 @@ class Api {
throw new Error(`Unexpected server response ${response.status}`); throw new Error(`Unexpected server response ${response.status}`);
} }
async userAuth(baseUrl, user) { async login(baseUrl, user) {
const url = userAuthUrl(baseUrl); const url = userTokenUrl(baseUrl);
console.log(`[Api] Checking auth for ${url}`); console.log(`[Api] Checking auth for ${url}`);
const response = await fetch(url, { const response = await fetch(url, {
headers: maybeWithBasicAuth({}, user) headers: maybeWithBasicAuth({}, user)
@ -135,6 +135,18 @@ class Api {
return json.token; 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) { async userStats(baseUrl) {
const url = userStatsUrl(baseUrl); const url = userStatsUrl(baseUrl);
console.log(`[Api] Fetching user stats ${url}`); console.log(`[Api] Fetching user stats ${url}`);

View file

@ -9,6 +9,10 @@ class Session {
localStorage.removeItem("token"); localStorage.removeItem("token");
} }
exists() {
return this.username() && this.token();
}
username() { username() {
return localStorage.getItem("user"); return localStorage.getItem("user");
} }

View file

@ -19,7 +19,7 @@ export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJ
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`; export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic)); export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`; 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 userAccountUrl = (baseUrl) => `${baseUrl}/user/account`;
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; export const expandUrl = (url) => [`https://${url}`, `http://${url}`];

View file

@ -246,7 +246,7 @@ const ProfileIcon = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const anchorRef = useRef(null); const anchorRef = useRef(null);
const username = session.username(); const navigate = useNavigate();
const handleToggleOpen = () => { const handleToggleOpen = () => {
setOpen((prevOpen) => !prevOpen); setOpen((prevOpen) => !prevOpen);
@ -272,7 +272,8 @@ const ProfileIcon = (props) => {
// TODO // TODO
}; };
const handleLogout = () => { const handleLogout = async () => {
await api.logout("http://localhost:2586"/*window.location.origin*/, session.token());
session.reset(); session.reset();
window.location.href = routes.app; window.location.href = routes.app;
}; };
@ -288,15 +289,15 @@ const ProfileIcon = (props) => {
return ( return (
<> <>
{username && {session.exists() &&
<IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen} sx={{marginRight: 0}} aria-label={t("xxxxxxx")}> <IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen} sx={{marginRight: 0}} aria-label={t("xxxxxxx")}>
<AccountCircleIcon/> <AccountCircleIcon/>
</IconButton> </IconButton>
} }
{!username && {!session.exists() &&
<> <>
<Button>Sign in</Button> <Button color="inherit" variant="outlined" onClick={() => navigate(routes.login)}>Sign in</Button>
<Button>Sign up</Button> <Button color="inherit" variant="outlined" onClick={() => navigate(routes.signup)}>Sign up</Button>
</> </>
} }
<Popper <Popper

View file

@ -36,7 +36,7 @@ const Login = () => {
username: data.get('email'), username: data.get('email'),
password: data.get('password'), 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}`); console.log(`[Api] User auth for user ${user.username} successful, token is ${token}`);
session.store(user.username, token); session.store(user.username, token);
window.location.href = routes.app; window.location.href = routes.app;

View file

@ -84,7 +84,10 @@ const NotificationList = (props) => {
useEffect(() => { useEffect(() => {
return () => { return () => {
setMaxCount(pageSize); setMaxCount(pageSize);
document.getElementById("main").scrollTo(0, 0); const main = document.getElementById("main");
if (main) {
main.scrollTo(0, 0);
}
} }
}, [props.id]); }, [props.id]);

View file

@ -441,6 +441,11 @@ const Language = () => {
const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" "); const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" ");
const lang = i18n.language ?? "en"; 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. // 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 // Languages names from: https://www.omniglot.com/language/names.htm
// Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l // Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l
@ -448,7 +453,7 @@ const Language = () => {
return ( return (
<Pref labelId={labelId} title={title}> <Pref labelId={labelId} title={title}>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}> <FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={lang} onChange={(ev) => i18n.changeLanguage(ev.target.value)} aria-labelledby={labelId}> <Select value={lang} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value="en">English</MenuItem> <MenuItem value="en">English</MenuItem>
<MenuItem value="id">Bahasa Indonesia</MenuItem> <MenuItem value="id">Bahasa Indonesia</MenuItem>
<MenuItem value="bg">Български</MenuItem> <MenuItem value="bg">Български</MenuItem>
@ -474,6 +479,10 @@ const Language = () => {
) )
}; };
const AccessControl = () => {
return <></>;
}
/*
const AccessControl = () => { const AccessControl = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0); const [dialogKey, setDialogKey] = useState(0);
@ -632,6 +641,6 @@ const AccessControlDialog = (props) => {
</Dialog> </Dialog>
); );
}; };
*/
export default Preferences; export default Preferences;

View file

@ -4,6 +4,7 @@ import {shortUrl} from "../app/utils";
const routes = { const routes = {
home: "/", home: "/",
login: "/login", login: "/login",
signup: "/signup",
app: config.appRoot, app: config.appRoot,
settings: "/settings", settings: "/settings",
subscription: "/:topic", subscription: "/:topic",