From 58e0179321ec524fd41c3f18b688b47f3e0e7bf6 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 24 Nov 2023 17:22:05 +0100 Subject: [PATCH] Add local login flow (#18717) --- src/auth/ha-auth-flow.ts | 54 ++-- src/auth/ha-authorize.ts | 68 +++-- src/auth/ha-local-auth-flow.ts | 334 +++++++++++++++++++++++++ src/auth/ha-pick-auth-provider.ts | 4 +- src/components/user/ha-person-badge.ts | 18 +- src/data/auth.ts | 1 + src/data/person.ts | 5 + 7 files changed, 424 insertions(+), 60 deletions(-) create mode 100644 src/auth/ha-local-auth-flow.ts diff --git a/src/auth/ha-auth-flow.ts b/src/auth/ha-auth-flow.ts index 9be7202c7766..d954417ca59f 100644 --- a/src/auth/ha-auth-flow.ts +++ b/src/auth/ha-auth-flow.ts @@ -29,18 +29,18 @@ export class HaAuthFlow extends LitElement { @property() public localize!: LocalizeFunc; + @property({ attribute: false }) public step?: DataEntryFlowStep; + + @property({ type: Boolean }) private storeToken = false; + @state() private _state: State = "loading"; @state() private _stepData?: Record; - @state() private _step?: DataEntryFlowStep; - @state() private _errorMessage?: string; @state() private _submitting = false; - @state() private _storeToken = false; - createRenderRoot() { return this; } @@ -48,27 +48,29 @@ export class HaAuthFlow extends LitElement { willUpdate(changedProps: PropertyValues) { super.willUpdate(changedProps); - if (!changedProps.has("_step")) { + if (!changedProps.has("step")) { return; } - if (!this._step) { + if (!this.step) { this._stepData = undefined; return; } - const oldStep = changedProps.get("_step") as HaAuthFlow["_step"]; + this._state = "step"; + + const oldStep = changedProps.get("step") as HaAuthFlow["step"]; if ( !oldStep || - this._step.flow_id !== oldStep.flow_id || - (this._step.type === "form" && + this.step.flow_id !== oldStep.flow_id || + (this.step.type === "form" && oldStep.type === "form" && - this._step.step_id !== oldStep.step_id) + this.step.step_id !== oldStep.step_id) ) { this._stepData = - this._step.type === "form" - ? computeInitialHaFormData(this._step.data_schema) + this.step.type === "form" + ? computeInitialHaFormData(this.step.data_schema) : undefined; } } @@ -117,7 +119,7 @@ export class HaAuthFlow extends LitElement { this._providerChanged(this.authProvider); } - if (!changedProps.has("_step") || this._step?.type !== "form") { + if (!changedProps.has("step") || this.step?.type !== "form") { return; } @@ -133,18 +135,18 @@ export class HaAuthFlow extends LitElement { private _renderForm() { switch (this._state) { case "step": - if (this._step == null) { + if (this.step == null) { return nothing; } return html` - ${this._renderStep(this._step)} + ${this._renderStep(this.step)}
- ${this._step.type === "form" + ${this.step.type === "form" ? this.localize("ui.panel.page-authorize.form.next") : this.localize("ui.panel.page-authorize.form.start_over")} @@ -205,7 +207,7 @@ export class HaAuthFlow extends LitElement { .label=${this.localize("ui.panel.page-authorize.store_token")} > @@ -218,12 +220,12 @@ export class HaAuthFlow extends LitElement { } private _storeTokenChanged(e: CustomEvent) { - this._storeToken = (e.currentTarget as HTMLInputElement).checked; + this.storeToken = (e.currentTarget as HTMLInputElement).checked; } private async _providerChanged(newProvider?: AuthProvider) { - if (this._step && this._step.type === "form") { - fetch(`/auth/login_flow/${this._step.flow_id}`, { + if (this.step && this.step.type === "form") { + fetch(`/auth/login_flow/${this.step.flow_id}`, { method: "DELETE", credentials: "same-origin", }).catch((err) => { @@ -260,7 +262,7 @@ export class HaAuthFlow extends LitElement { return; } - this._step = data; + this.step = data; this._state = "step"; } else { this._state = "error"; @@ -288,7 +290,7 @@ export class HaAuthFlow extends LitElement { if (this.oauth2State) { url += `&state=${encodeURIComponent(this.oauth2State)}`; } - if (this._storeToken) { + if (this.storeToken) { url += `&storeToken=true`; } @@ -331,10 +333,10 @@ export class HaAuthFlow extends LitElement { private async _handleSubmit(ev: Event) { ev.preventDefault(); - if (this._step == null) { + if (this.step == null) { return; } - if (this._step.type !== "form") { + if (this.step.type !== "form") { this._providerChanged(this.authProvider); return; } @@ -343,7 +345,7 @@ export class HaAuthFlow extends LitElement { const postData = { ...this._stepData, client_id: this.clientId }; try { - const response = await fetch(`/auth/login_flow/${this._step.flow_id}`, { + const response = await fetch(`/auth/login_flow/${this.step.flow_id}`, { method: "POST", credentials: "same-origin", body: JSON.stringify(postData), @@ -361,7 +363,7 @@ export class HaAuthFlow extends LitElement { this._redirect(newStep.result); return; } - this._step = newStep; + this.step = newStep; this._state = "step"; } catch (err: any) { // eslint-disable-next-line no-console diff --git a/src/auth/ha-authorize.ts b/src/auth/ha-authorize.ts index 54d59bd8e962..45e1e0ae8e95 100644 --- a/src/auth/ha-authorize.ts +++ b/src/auth/ha-authorize.ts @@ -13,6 +13,7 @@ import { import { litLocalizeLiteMixin } from "../mixins/lit-localize-lite-mixin"; import { registerServiceWorker } from "../util/register-service-worker"; import "./ha-auth-flow"; +import "./ha-local-auth-flow"; import("./ha-pick-auth-provider"); @@ -39,6 +40,8 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) { @state() private _error?: string; + @state() private _forceDefaultLogin = false; + constructor() { super(); const query = extractSearchParamsObject() as AuthUrlSearchParams; @@ -121,32 +124,43 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) { })} ` : html`

${this.localize("ui.panel.page-authorize.authorizing")}

`} - ${inactiveProviders.length > 0 - ? html`

- ${this.localize("ui.panel.page-authorize.logging_in_with", { - authProviderName: html`${this._authProvider!.name}`, - })} -

` - : nothing} - - - - ${inactiveProviders.length > 0 - ? html` - ` + : html`${inactiveProviders.length > 0 + ? html`

+ ${this.localize("ui.panel.page-authorize.logging_in_with", { + authProviderName: html`${this._authProvider!.name}`, + })} +

` + : nothing} +
- ` - : ""} + .redirectUri=${this.redirectUri} + .oauth2State=${this.oauth2State} + .authProvider=${this._authProvider} + .localize=${this.localize} + > + ${inactiveProviders.length > 0 + ? html` + + ` + : ""}`} `; } @@ -245,6 +259,10 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) { } } + private _handleDefaultLoginFlow() { + this._forceDefaultLogin = true; + } + private async _handleAuthProviderPick(ev) { this._authProvider = ev.detail; } diff --git a/src/auth/ha-local-auth-flow.ts b/src/auth/ha-local-auth-flow.ts new file mode 100644 index 000000000000..ce15e0405679 --- /dev/null +++ b/src/auth/ha-local-auth-flow.ts @@ -0,0 +1,334 @@ +/* eslint-disable lit/prefer-static-styles */ +import "@material/mwc-button"; +import { html, LitElement, nothing, PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { LocalizeFunc } from "../common/translations/localize"; +import "../components/ha-alert"; +import "../components/user/ha-person-badge"; +import { AuthProvider } from "../data/auth"; +import { listPersons } from "../data/person"; +import "./ha-auth-textfield"; +import type { HaAuthTextField } from "./ha-auth-textfield"; +import { DataEntryFlowStep } from "../data/data_entry_flow"; +import { fireEvent } from "../common/dom/fire_event"; + +@customElement("ha-local-auth-flow") +export class HaLocalAuthFlow extends LitElement { + @property({ attribute: false }) public authProvider?: AuthProvider; + + @property({ attribute: false }) public authProviders?: AuthProvider[]; + + @property() public clientId?: string; + + @property() public redirectUri?: string; + + @property() public oauth2State?: string; + + @property() public localize!: LocalizeFunc; + + @state() private _error?: string; + + @state() private _step?: DataEntryFlowStep; + + @state() private _submitting = false; + + @state() private _persons?: Promise>; + + @state() private _selectedUser?: string; + + createRenderRoot() { + return this; + } + + willUpdate(changedProps: PropertyValues) { + super.willUpdate(changedProps); + + if (!this.hasUpdated) { + this._load(); + } + } + + protected render() { + if (!this.authProvider?.users || !this._persons) { + return nothing; + } + return html` + + ${this._error + ? html`${this._error}` + : ""} + ${this._step + ? html`` + : this._selectedUser + ? html` +
+ + ${this.localize("ui.panel.page-authorize.form.next")} + +
+ ` + : html`
+ ${Object.keys(this.authProvider.users).map((userId) => { + const person = this._persons![userId]; + return html`
+ +

${person.name}

+
`; + })} +
+ + Other options + + `} + `; + } + + protected firstUpdated(changedProps: PropertyValues) { + super.firstUpdated(changedProps); + + this.addEventListener("keypress", (ev) => { + if (ev.key === "Enter") { + this._handleSubmit(ev); + } + }); + } + + protected updated(changedProps: PropertyValues) { + if (changedProps.has("_selectedUser") && this._selectedUser) { + const passwordElement = this.renderRoot.querySelector( + "#password" + ) as HaAuthTextField; + passwordElement.updateComplete.then(() => { + passwordElement.focus(); + }); + } + } + + private async _load() { + this._persons = await (await listPersons()).json(); + } + + private async _personSelected(ev) { + const userId = ev.currentTarget.userId; + if (this.authProviders?.find((prv) => prv.type === "trusted_networks")) { + try { + const flowResponse = await fetch("/auth/login_flow", { + method: "POST", + credentials: "same-origin", + body: JSON.stringify({ + client_id: this.clientId, + handler: ["trusted_networks", null], + redirect_uri: this.redirectUri, + }), + }); + + const data = await flowResponse.json(); + + if (data.type === "create_entry") { + this._redirect(data.result); + return; + } + + try { + if (!data.data_schema[0].options.find((opt) => opt[0] === userId)) { + throw new Error("User not available"); + } + + const postData = { user: userId, client_id: this.clientId }; + + const response = await fetch(`/auth/login_flow/${data.flow_id}`, { + method: "POST", + credentials: "same-origin", + body: JSON.stringify(postData), + }); + + if (response.ok) { + const result = await response.json(); + + if (result.type === "create_entry") { + this._redirect(result.result); + return; + } + } else { + throw new Error("Invalid response"); + } + } catch { + fetch(`/auth/login_flow/${data.flow_id}`, { + method: "DELETE", + credentials: "same-origin", + }).catch((err) => { + // eslint-disable-next-line no-console + console.error("Error delete obsoleted auth flow", err); + }); + } + } catch { + // Ignore + } + } + this._selectedUser = userId; + } + + private async _handleSubmit(ev: Event) { + ev.preventDefault(); + + if (!this.authProvider?.users || !this._selectedUser) { + return; + } + + this._submitting = true; + + const flowResponse = await fetch("/auth/login_flow", { + method: "POST", + credentials: "same-origin", + body: JSON.stringify({ + client_id: this.clientId, + handler: ["homeassistant", null], + redirect_uri: this.redirectUri, + }), + }); + + const data = await flowResponse.json(); + + const postData = { + username: this.authProvider.users[this._selectedUser], + password: (this.renderRoot.querySelector("#password") as HaAuthTextField) + .value, + client_id: this.clientId, + }; + + try { + const response = await fetch(`/auth/login_flow/${data.flow_id}`, { + method: "POST", + credentials: "same-origin", + body: JSON.stringify(postData), + }); + + const newStep = await response.json(); + + if (response.status === 403) { + this._error = newStep.message; + return; + } + + if (newStep.type === "create_entry") { + this._redirect(newStep.result); + return; + } + + if (newStep.errors.base) { + this._error = this.localize( + `ui.panel.page-authorize.form.providers.homeassistant.error.${newStep.errors.base}` + ); + return; + } + + this._step = newStep; + } catch (err: any) { + // eslint-disable-next-line no-console + console.error("Error submitting step", err); + this._error = this.localize("ui.panel.page-authorize.form.unknown_error"); + } finally { + this._submitting = false; + } + } + + private _redirect(authCode: string) { + // OAuth 2: 3.1.2 we need to retain query component of a redirect URI + let url = this.redirectUri!; + if (!url.includes("?")) { + url += "?"; + } else if (!url.endsWith("&")) { + url += "&"; + } + + url += `code=${encodeURIComponent(authCode)}`; + + if (this.oauth2State) { + url += `&state=${encodeURIComponent(this.oauth2State)}`; + } + url += `&storeToken=true`; + + document.location.assign(url); + } + + private _otherLogin() { + fireEvent(this, "default-login-flow"); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-local-auth-flow": HaLocalAuthFlow; + } + interface HASSDomEvents { + "default-login-flow": undefined; + } +} diff --git a/src/auth/ha-pick-auth-provider.ts b/src/auth/ha-pick-auth-provider.ts index 963874393aec..01878a1c2236 100644 --- a/src/auth/ha-pick-auth-provider.ts +++ b/src/auth/ha-pick-auth-provider.ts @@ -35,8 +35,8 @@ export class HaPickAuthProvider extends LitElement { ` - )} + )} + `; } diff --git a/src/components/user/ha-person-badge.ts b/src/components/user/ha-person-badge.ts index 8c1cb49c27f7..95a8729ecf23 100644 --- a/src/components/user/ha-person-badge.ts +++ b/src/components/user/ha-person-badge.ts @@ -33,25 +33,29 @@ class PersonBadge extends LitElement { static get styles(): CSSResultGroup { return css` :host { - display: contents; - } - .picture { width: 40px; height: 40px; + display: block; + } + .picture { + width: 100%; + height: 100%; background-size: cover; border-radius: 50%; } .initials { - display: inline-block; + display: inline-flex; + justify-content: center; + align-items: center; box-sizing: border-box; - width: 40px; - line-height: 40px; + width: 100%; + height: 100%; border-radius: 50%; - text-align: center; background-color: var(--light-primary-color); text-decoration: none; color: var(--text-light-primary-color, var(--primary-text-color)); overflow: hidden; + font-size: var(--person-badge-font-size, 1em); } .initials.long { font-size: 80%; diff --git a/src/data/auth.ts b/src/data/auth.ts index 97d354123836..17309f491be8 100644 --- a/src/data/auth.ts +++ b/src/data/auth.ts @@ -11,6 +11,7 @@ export interface AuthProvider { name: string; id: string; type: string; + users?: Record; } export interface Credential { diff --git a/src/data/person.ts b/src/data/person.ts index eb3b35872961..ec537bc7042b 100644 --- a/src/data/person.ts +++ b/src/data/person.ts @@ -21,6 +21,11 @@ export const fetchPersons = (hass: HomeAssistant) => config: Person[]; }>({ type: "person/list" }); +export const listPersons = () => + fetch("/api/person/list", { + credentials: "same-origin", + }); + export const createPerson = ( hass: HomeAssistant, values: PersonMutableParams