Token login
This commit is contained in:
parent
35657a7bbd
commit
8dcb4be8a8
11 changed files with 94 additions and 26 deletions
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}`);
|
||||||
|
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}`];
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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",
|
||||||
|
|
Loading…
Reference in a new issue