Merge pull request #553 from kolplattformen/feature/hjarntorget-auth-bugs

Feature/hjarntorget auth bugs
This commit is contained in:
Viktor Sarström 2021-11-24 07:22:32 +01:00 committed by GitHub
commit 368fb646d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 453 additions and 308 deletions

View File

@ -1,36 +1,20 @@
import * as eva from '@eva-design/eva'
import AsyncStorage from '@react-native-async-storage/async-storage'
import CookieManager from '@react-native-cookies/cookies'
import initSkolplattformen, {
features as featuresSkolplattformen,
} from '@skolplattformen/api-skolplattformen'
import initHjarntorget, {
features as featuresHjarntorget,
} from '@skolplattformen/api-hjarntorget'
import { ApiProvider } from '@skolplattformen/hooks'
import { ApplicationProvider, IconRegistry } from '@ui-kitten/components'
import { ApiProvider, Reporter } from '@skolplattformen/hooks'
import { ApplicationProvider, IconRegistry, Text } from '@ui-kitten/components'
import { EvaIconsPack } from '@ui-kitten/eva-icons'
import React, { useEffect, useState } from 'react'
import { StatusBar, useColorScheme } from 'react-native'
import React from 'react'
import { StatusBar, useColorScheme, View } from 'react-native'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { AppNavigator } from './components/navigation.component'
import { FeatureProvider } from './context/feature/featureContext'
import { LanguageProvider } from './context/language/languageContext'
import { SchoolPlatformProvider } from './context/schoolPlatform/schoolPlatformContext'
import { schoolPlatforms } from './data/schoolPlatforms'
import { default as customMapping } from './design/mapping.json'
import { darkTheme, lightTheme } from './design/themes'
import useSettingsStorage from './hooks/useSettingsStorage'
import { translations } from './utils/translation'
import { Reporter } from '@skolplattformen/hooks'
import { Api } from '@skolplattformen/api'
import { FeatureProvider } from './context/feature/featureContext'
const ApiList = new Map<string, Api>([
// @ts-expect-error Why is fetch failing here?
['stockholm-skolplattformen', initSkolplattformen(fetch, CookieManager)],
// @ts-expect-error Why is fetch failing here?
['goteborg-hjarntorget', initHjarntorget(fetch, CookieManager)],
])
const reporter: Reporter | undefined = __DEV__
? {
@ -77,15 +61,23 @@ export default () => {
const systemTheme = useColorScheme()
const colorScheme = usingSystemTheme ? systemTheme : theme
// Crash
//const api = ApiList.get(currentSchoolPlatform)!
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const api = ApiList.get('goteborg-hjarntorget')!
const platform = schoolPlatforms.find((pf) => pf.id === currentSchoolPlatform)
if (!platform)
return (
<View>
<Text>ERROR</Text>
</View>
)
return (
<FeatureProvider features={featuresHjarntorget}>
<FeatureProvider features={platform.features}>
<SchoolPlatformProvider>
<ApiProvider api={api} storage={AsyncStorage} reporter={reporter}>
<ApiProvider
api={platform.api}
storage={AsyncStorage}
reporter={reporter}
>
<SafeAreaProvider>
<StatusBar
backgroundColor={colorScheme === 'dark' ? '#2E3137' : '#FFF'}

View File

@ -23,10 +23,11 @@ import {
} from 'react-native'
import { schema } from '../app.json'
import { SchoolPlatformContext } from '../context/schoolPlatform/schoolPlatformContext'
import { schoolPlatforms } from '../data/schoolPlatforms'
import { useFeature } from '../hooks/useFeature'
import useSettingsStorage from '../hooks/useSettingsStorage'
import { useTranslation } from '../hooks/useTranslation'
import { Layout as LayoutStyle, Sizing, Typography } from '../styles'
import { Layout } from '../styles'
import {
CheckIcon,
CloseOutlineIcon,
@ -67,39 +68,32 @@ export const Login = () => {
const [personalIdNumber, setPersonalIdNumber] = useSettingsStorage(
'cachedPersonalIdentityNumber'
)
const [loginMethodIndex, setLoginMethodIndex] =
useSettingsStorage('loginMethodIndex')
const [loginMethodId, setLoginMethodId] = useSettingsStorage('loginMethodId')
const loginBankIdSameDevice = useFeature('LOGIN_BANK_ID_SAME_DEVICE')
const { currentSchoolPlatform, changeSchoolPlatform } = useContext(
SchoolPlatformContext
)
console.log({ loginBankIdSameDevice })
const { t } = useTranslation()
const valid = Personnummer.valid(personalIdNumber)
const loginMethods = [
t('auth.bankid.OpenOnThisDevice'),
t('auth.bankid.OpenOnAnotherDevice'),
t('auth.loginAsTestUser'),
]
//if (loginBankIdSameDevice) {
// loginMethods.unshift(t('auth.bankid.OpenOnThisDevice'))
//}
// move this to a central location?
const schoolPlatforms = [
{
id: 'stockholm-skolplattformen',
displayName: 'Stockholm stad (Skolplattformen)',
id: 'thisdevice',
title: t('auth.bankid.OpenOnThisDevice'),
enabled: loginBankIdSameDevice,
},
{
id: 'goteborg-hjarnkontoret',
displayName: 'Göteborg stad (Hjärntorget)',
id: 'otherdevice',
title: t('auth.bankid.OpenOnAnotherDevice'),
enabled: true,
},
]
{ id: 'testuser', title: t('auth.loginAsTestUser'), enabled: true },
] as const
const loginHandler = async () => {
showModal(false)
@ -124,7 +118,8 @@ export const Login = () => {
const openBankId = (token: string) => {
try {
const redirect = loginMethodIndex === 0 ? encodeURIComponent(schema) : ''
const redirect =
loginMethodId === 'thisdevice' ? encodeURIComponent(schema) : ''
const bankIdUrl =
Platform.OS === 'ios'
? `https://app.bankid.com/?autostarttoken=${token}&redirect=${redirect}`
@ -136,18 +131,18 @@ export const Login = () => {
}
const startLogin = async (text: string) => {
if (loginMethodIndex < 2) {
if (loginMethodId === 'thisdevice' || loginMethodId === 'otherdevice') {
showModal(true)
let ssn
if (loginMethodIndex === 1) {
if (loginMethodId === 'otherdevice') {
ssn = Personnummer.parse(text).format(true)
setPersonalIdNumber(ssn)
}
const status = await api.login(ssn)
setCancelLoginRequest(() => () => status.cancel())
if (status.token !== 'fake' && loginMethodIndex === 0) {
if (status.token !== 'fake' && loginMethodId === 'thisdevice') {
openBankId(status.token)
}
status.on('PENDING', () => console.log('BankID app not yet opened'))
@ -168,10 +163,16 @@ export const Login = () => {
const styles = useStyleSheet(themedStyles)
const enabledLoginMethods = loginMethods.filter((method) => method.enabled)
const currentLoginMethod =
enabledLoginMethods.find((method) => method.id === loginMethodId) ||
enabledLoginMethods[0]
return (
<>
<View style={styles.loginForm}>
{loginMethodIndex === 0 && (
{loginMethodId === 'otherdevice' && (
<Input
accessible={true}
label={t('general.socialSecurityNumber')}
@ -206,12 +207,12 @@ export const Login = () => {
onPress={() => startLogin(personalIdNumber)}
style={styles.loginButton}
appearance="ghost"
disabled={loginMethodIndex === 1 && !valid}
disabled={loginMethodId === 'otherdevice' && !valid}
status="primary"
accessoryLeft={BankId}
size="medium"
>
{loginMethods[loginMethodIndex]}
{currentLoginMethod.title}
</Button>
<Button
accessible={true}
@ -253,17 +254,17 @@ export const Login = () => {
{t('auth.chooseLoginMethod')}
</Text>
<List
data={loginMethods}
data={enabledLoginMethods}
ItemSeparatorComponent={Divider}
renderItem={({ item, index }) => (
<ListItem
title={item}
title={item.title}
accessible={true}
accessoryRight={
loginMethodIndex === index ? CheckIcon : undefined
loginMethodId === item.id ? CheckIcon : undefined
}
onPress={() => {
setLoginMethodIndex(index)
setLoginMethodId(item.id)
setShowLoginMethod(false)
}}
/>
@ -330,3 +331,30 @@ export const Login = () => {
</>
)
}
const themedStyles = StyleService.create({
backdrop: {
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
loginForm: {
...Layout.mainAxis.flexStart,
},
pnrInput: { minHeight: 70 },
loginButtonGroup: {
minHeight: 45,
},
loginButton: { ...Layout.flex.full },
loginMethodButton: { width: 45 },
modal: {
width: '90%',
},
bankIdLoading: { margin: 10 },
cancelButtonStyle: { marginTop: 15 },
icon: {
width: 20,
height: 20,
},
platformPicker: {
width: '100%',
},
})

View File

@ -0,0 +1,22 @@
import CookieManager from '@react-native-cookies/cookies'
import initHjarntorget, {
features as featuresHjarntorget,
} from '@skolplattformen/api-hjarntorget'
import initSkolplattformen, {
features as featuresSkolPlattformen,
} from '@skolplattformen/api-skolplattformen'
export const schoolPlatforms = [
{
id: 'stockholm-skolplattformen',
displayName: 'Stockholm stad (Skolplattformen)',
api: initSkolplattformen(fetch, CookieManager),
features: featuresSkolPlattformen,
},
{
id: 'goteborg-hjarntorget',
displayName: 'Göteborg stad (Hjärntorget)',
api: initHjarntorget(fetch, CookieManager),
features: featuresHjarntorget,
},
]

View File

@ -5,11 +5,13 @@ import AppStorage from '../services/appStorage'
export const settingsState = proxy({
hydrated: false,
settings: {
loginMethodIndex: 0,
loginMethodId: 'thisdevice' as 'thisdevice' | 'otherdevice' | 'testuser',
usingSystemTheme: true,
theme: 'light',
cachedPersonalIdentityNumber: '',
currentSchoolPlatform: 'stockholm-skolplattformen',
currentSchoolPlatform: 'stockholm-skolplattformen' as
| 'stockholm-skolplattformen'
| 'goteborg-hjarntorget',
},
})

View File

@ -1,78 +1,78 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { DateTime, FixedOffsetZone } from 'luxon'
import { EventEmitter } from 'events'
import * as html from 'node-html-parser'
import { decode } from 'he'
import { toMarkdown, Api, URLSearchParams, LoginStatusChecker, CalendarItem,
import {
Api,
CalendarItem,
Classmate,
CookieManager,
EtjanstChild,
Fetch,
Fetcher,
FetcherOptions,
LoginStatusChecker,
MenuItem,
NewsItem,
Notification,
ScheduleItem,
Skola24Child,
TimetableEntry,
toMarkdown,
URLSearchParams,
User,
Fetcher,
FetcherOptions,
wrap
wrap,
} from '@skolplattformen/api'
import { EventEmitter } from 'events'
import { decode } from 'he'
import { DateTime, FixedOffsetZone } from 'luxon'
import * as html from 'node-html-parser'
import { fakeFetcher } from './fake/fakeFetcher'
import { checkStatus } from './loginStatus'
import { extractMvghostRequestBody, parseCalendarItem } from './parse/parsers'
import {
beginLoginUrl,
beginBankIdUrl,
beginLoginUrl,
calendarEventUrl,
calendarsUrl,
currentUserUrl,
initBankIdUrl,
fullImageUrl,
hjarntorgetEventsUrl,
hjarntorgetUrl,
infoSetReadUrl,
infoUrl,
initBankIdUrl,
lessonsUrl,
membersWithRoleUrl,
mvghostUrl,
myChildrenUrl,
shibbolethLoginUrlBase,
rolesInEventUrl,
shibbolethLoginUrl,
shibbolethLoginUrlBase,
verifyUrlBase,
wallMessagesUrl,
calendarsUrl,
calendarEventUrl
} from './routes'
import { fakeFetcher } from './fake/fakeFetcher'
function getDateOfISOWeek(week: number, year: number,) {
function getDateOfISOWeek(week: number, year: number) {
const simple = new Date(year, 0, 1 + (week - 1) * 7)
const dow = simple.getDay()
const ISOweekStart = simple
if (dow <= 4)
ISOweekStart.setDate(simple.getDate() - simple.getDay() + 1)
else
ISOweekStart.setDate(simple.getDate() + 8 - simple.getDay())
if (dow <= 4) ISOweekStart.setDate(simple.getDate() - simple.getDay() + 1)
else ISOweekStart.setDate(simple.getDate() + 8 - simple.getDay())
return ISOweekStart
}
export class ApiHjarntorget extends EventEmitter implements Api {
private fetch: Fetcher
private realFetcher: Fetcher
private realFetcher: Fetcher
private personalNumber?: string
private cookieManager: CookieManager
public isLoggedIn: boolean = false
public isLoggedIn = false
private _isFake: boolean = false;
private _isFake = false
public set isFake(fake: boolean) {
this._isFake = fake
if(this._isFake) {
if (this._isFake) {
this.fetch = fakeFetcher
} else {
this.fetch = this.realFetcher
@ -84,36 +84,42 @@ export class ApiHjarntorget extends EventEmitter implements Api {
}
constructor(
fetch: Fetch,
fetch: typeof global.fetch,
cookieManager: CookieManager,
options?: FetcherOptions
) {
super()
this.fetch = wrap(fetch, options);
this.realFetcher = this.fetch;
this.fetch = wrap(fetch, options)
this.realFetcher = this.fetch
this.cookieManager = cookieManager
}
public replaceFetcher(fetcher: Fetcher) {
this.fetch = fetcher;
this.fetch = fetcher
}
async getSchedule(child: EtjanstChild, from: DateTime, to: DateTime): Promise<(CalendarItem & ScheduleItem)[]> {
async getSchedule(
child: EtjanstChild,
from: DateTime,
to: DateTime
): Promise<(CalendarItem & ScheduleItem)[]> {
const lessonParams = {
forUser: child.id,
startDateIso: from.toISODate(),
endDateIso: to.toISODate(),
}
const lessonsResponse = await this.fetch(`lessons-${lessonParams.forUser}`, lessonsUrl(lessonParams))
const lessonsResponse = await this.fetch(
`lessons-${lessonParams.forUser}`,
lessonsUrl(lessonParams)
)
const lessonsResponseJson: any[] = await lessonsResponse.json()
return lessonsResponseJson.map(l => {
return lessonsResponseJson.map((l) => {
const start = DateTime.fromMillis(l.startDate.ts, {
zone: FixedOffsetZone.instance(l.startDate.timezoneOffsetMinutes)
zone: FixedOffsetZone.instance(l.startDate.timezoneOffsetMinutes),
})
const end = DateTime.fromMillis(l.endDate.ts, {
zone: FixedOffsetZone.instance(l.endDate.timezoneOffsetMinutes)
zone: FixedOffsetZone.instance(l.endDate.timezoneOffsetMinutes),
})
return {
id: l.id,
@ -133,7 +139,6 @@ export class ApiHjarntorget extends EventEmitter implements Api {
}
async setSessionCookie(sessionCookie: string): Promise<void> {
await this.fetch('login-cookie', hjarntorgetUrl, {
headers: {
cookie: sessionCookie,
@ -151,7 +156,7 @@ export class ApiHjarntorget extends EventEmitter implements Api {
}
async getUser(): Promise<User> {
console.log("fetching user")
console.log('fetching user')
const currentUserResponse = await this.fetch('current-user', currentUserUrl)
if (currentUserResponse.status !== 200) {
return { isAuthenticated: false }
@ -165,60 +170,70 @@ export class ApiHjarntorget extends EventEmitter implements Api {
if (!this.isLoggedIn) {
throw new Error('Not logged in...')
}
console.log("fetching children")
console.log('fetching children')
const myChildrenResponse = await this.fetch('my-children', myChildrenUrl)
const myChildrenResponseJson: any[] = await myChildrenResponse.json()
return myChildrenResponseJson.map(c => ({
id: c.id,
sdsId: c.id,
personGuid: c.id,
firstName: c.firstName,
lastName: c.lastName,
name: `${c.firstName} ${c.lastName}`,
} as (Skola24Child & EtjanstChild)))
return myChildrenResponseJson.map(
(c) =>
({
id: c.id,
sdsId: c.id,
personGuid: c.id,
firstName: c.firstName,
lastName: c.lastName,
name: `${c.firstName} ${c.lastName}`,
} as Skola24Child & EtjanstChild)
)
}
async getCalendar(child: EtjanstChild): Promise<CalendarItem[]> {
const childEventsAndMembers = await this.getChildEventsWithAssociatedMembers(child)
const childEventsAndMembers =
await this.getChildEventsWithAssociatedMembers(child)
// This fetches the calendars search page on Hjärntorget.
// This fetches the calendars search page on Hjärntorget.
// It is used (at least at one school) for homework schedule
// The Id for the "event" that the calendar belongs to is not the same as the ones
// The Id for the "event" that the calendar belongs to is not the same as the ones
// fetched using the API... So we match them by name :/
const calendarsResponse = await this.fetch('calendars', calendarsUrl)
const calendarsResponseText = await calendarsResponse.text()
const calendarsDoc = html.parse(decode(calendarsResponseText))
const calendarCheckboxes = Array.from(calendarsDoc.querySelectorAll('.calendarPageContainer input.checkbox'))
const calendarCheckboxes = Array.from(
calendarsDoc.querySelectorAll('.calendarPageContainer input.checkbox')
)
let calendarItems: CalendarItem[] = []
for (let i = 0; i < calendarCheckboxes.length; i++) {
const calendarId = calendarCheckboxes[i].getAttribute('value') || ""
const calendarId = calendarCheckboxes[i].getAttribute('value') || ''
const today = DateTime.fromJSDate(new Date())
const start = today.toISODate()
const end = today.plus({ days: 30 }).toISODate()
const calendarResponse = await this.fetch(`calendar-${calendarId}`, calendarEventUrl(calendarId, start, end))
const calendarResponse = await this.fetch(
`calendar-${calendarId}`,
calendarEventUrl(calendarId, start, end)
)
const calendarResponseText = await calendarResponse.text()
const calendarDoc = html.parse(decode(calendarResponseText))
const calendarRows = Array.from(calendarDoc.querySelectorAll('.default-table tr'))
const calendarRows = Array.from(
calendarDoc.querySelectorAll('.default-table tr')
)
if (!calendarRows.length) {
continue
}
calendarRows.shift()
const eventName = calendarRows.shift()?.textContent
if (childEventsAndMembers.some(e => e.name === eventName)) {
if (childEventsAndMembers.some((e) => e.name === eventName)) {
const items: CalendarItem[] = calendarRows.map(parseCalendarItem)
calendarItems = calendarItems.concat(items)
}
}
return calendarItems;
return calendarItems
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -239,11 +254,11 @@ export class ApiHjarntorget extends EventEmitter implements Api {
const infoResponse = await this.fetch('info', infoUrl)
const infoResponseJson: any[] = await infoResponse.json()
// TODO: Filter out read messages?
return infoResponseJson.map(i => {
const body = html.parse(decode(i.body || ""))
return infoResponseJson.map((i) => {
const body = html.parse(decode(i.body || ''))
const bodyText = toMarkdown(i.body)
const introText = body.innerText || ""
const introText = body.innerText || ''
const publishedDate = new Date(i.created.ts)
return {
@ -254,13 +269,12 @@ export class ApiHjarntorget extends EventEmitter implements Api {
body: bodyText,
published: publishedDate.toISOString(),
modified: publishedDate.toISOString(),
fullImageUrl: i.creator && fullImageUrl(i.creator.imagePath)
fullImageUrl: i.creator && fullImageUrl(i.creator.imagePath),
}
})
}
async getNewsDetails(_child: EtjanstChild, item: NewsItem): Promise<any> {
await this.fetch('infoSetReadUrl', infoSetReadUrl(item), {
method: 'POST',
})
@ -278,41 +292,73 @@ export class ApiHjarntorget extends EventEmitter implements Api {
}
async getChildEventsWithAssociatedMembers(child: EtjanstChild) {
const hjarntorgetEventsResponse = await this.fetch('events', hjarntorgetEventsUrl)
const hjarntorgetEventsResponseJson: any[] = await hjarntorgetEventsResponse.json()
const membersInEvents = await Promise.all(hjarntorgetEventsResponseJson.filter(e => e.state === 'ONGOING')
.map(async e => {
const eventId = e.id as number
const hjarntorgetEventsResponse = await this.fetch(
'events',
hjarntorgetEventsUrl
)
const hjarntorgetEventsResponseJson: any[] =
await hjarntorgetEventsResponse.json()
const membersInEvents = await Promise.all(
hjarntorgetEventsResponseJson
.filter((e) => e.state === 'ONGOING')
.map(async (e) => {
const eventId = e.id as number
const rolesInEvenResponse = await this.fetch(`roles-in-event-${eventId}`, rolesInEventUrl(eventId))
const rolesInEvenResponseJson: any[] = await rolesInEvenResponse.json()
const rolesInEvenResponse = await this.fetch(
`roles-in-event-${eventId}`,
rolesInEventUrl(eventId)
)
const rolesInEvenResponseJson: any[] =
await rolesInEvenResponse.json()
const eventMembers = await Promise.all(rolesInEvenResponseJson.map(async r => {
const roleId = r.id
const membersWithRoleResponse = await this.fetch(`event-role-members-${eventId}-${roleId}`, membersWithRoleUrl(eventId, roleId))
const membersWithRoleResponseJson: any[] = await membersWithRoleResponse.json()
return membersWithRoleResponseJson
}))
return { eventId, name: e.name as string, eventMembers: ([] as any[]).concat(...eventMembers) }
}))
return membersInEvents
.filter(e => e.eventMembers.find(p => p.id === child.id))
const eventMembers = await Promise.all(
rolesInEvenResponseJson.map(async (r) => {
const roleId = r.id
const membersWithRoleResponse = await this.fetch(
`event-role-members-${eventId}-${roleId}`,
membersWithRoleUrl(eventId, roleId)
)
const membersWithRoleResponseJson: any[] =
await membersWithRoleResponse.json()
return membersWithRoleResponseJson
})
)
return {
eventId,
name: e.name as string,
eventMembers: ([] as any[]).concat(...eventMembers),
}
})
)
return membersInEvents.filter((e) =>
e.eventMembers.find((p) => p.id === child.id)
)
}
async getNotifications(child: EtjanstChild): Promise<Notification[]> {
const childEventsAndMembers =
await this.getChildEventsWithAssociatedMembers(child)
const membersInChildsEvents = childEventsAndMembers.reduce(
(acc, e) => acc.concat(e.eventMembers),
[] as any[]
)
const childEventsAndMembers = await this.getChildEventsWithAssociatedMembers(child)
const membersInChildsEvents = childEventsAndMembers.reduce((acc, e) => acc.concat(e.eventMembers), ([] as any[]))
const wallMessagesResponse = await this.fetch('wall-events', wallMessagesUrl)
const wallMessagesResponse = await this.fetch(
'wall-events',
wallMessagesUrl
)
const wallMessagesResponseJson: any[] = await wallMessagesResponse.json()
return wallMessagesResponseJson.filter(message =>
membersInChildsEvents.find(member => member.id === message.creator.id))
.map(message => {
return wallMessagesResponseJson
.filter((message) =>
membersInChildsEvents.find((member) => member.id === message.creator.id)
)
.map((message) => {
const createdDate = new Date(message.created.ts)
return {
id: message.id,
sender: message.creator && `${message.creator.firstName} ${message.creator.lastName}`,
sender:
message.creator &&
`${message.creator.firstName} ${message.creator.lastName}`,
dateCreated: createdDate.toISOString(),
message: message.body,
url: message.url,
@ -323,7 +369,7 @@ export class ApiHjarntorget extends EventEmitter implements Api {
})
}
async getSkola24Children(): Promise<(Skola24Child)[]> {
async getSkola24Children(): Promise<Skola24Child[]> {
if (!this.isLoggedIn) {
throw new Error('Not logged in...')
}
@ -331,8 +377,12 @@ export class ApiHjarntorget extends EventEmitter implements Api {
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getTimetable(child: Skola24Child, week: number, year: number, _lang: string): Promise<TimetableEntry[]> {
async getTimetable(
child: Skola24Child,
week: number,
year: number,
_lang: string
): Promise<TimetableEntry[]> {
const startDate = DateTime.fromJSDate(getDateOfISOWeek(week, year))
const endDate = startDate.plus({ days: 7 })
@ -341,15 +391,18 @@ export class ApiHjarntorget extends EventEmitter implements Api {
startDateIso: startDate.toISODate(),
endDateIso: endDate.toISODate(),
}
const lessonsResponse = await this.fetch(`lessons-${lessonParams.forUser}`, lessonsUrl(lessonParams))
const lessonsResponse = await this.fetch(
`lessons-${lessonParams.forUser}`,
lessonsUrl(lessonParams)
)
const lessonsResponseJson: any[] = await lessonsResponse.json()
return lessonsResponseJson.map(l => {
return lessonsResponseJson.map((l) => {
const start = DateTime.fromMillis(l.startDate.ts, {
zone: FixedOffsetZone.instance(l.startDate.timezoneOffsetMinutes)
zone: FixedOffsetZone.instance(l.startDate.timezoneOffsetMinutes),
})
const end = DateTime.fromMillis(l.endDate.ts, {
zone: FixedOffsetZone.instance(l.endDate.timezoneOffsetMinutes)
zone: FixedOffsetZone.instance(l.endDate.timezoneOffsetMinutes),
})
return {
id: l.id,
@ -363,7 +416,6 @@ export class ApiHjarntorget extends EventEmitter implements Api {
dateEnd: start.toISODate(),
} as TimetableEntry
})
}
async logout(): Promise<void> {
@ -375,55 +427,77 @@ export class ApiHjarntorget extends EventEmitter implements Api {
public async login(personalNumber?: string): Promise<LoginStatusChecker> {
// short circut the bank-id login if in fake mode
if (personalNumber !== undefined && personalNumber.endsWith('1212121212')) return this.fakeMode()
if (personalNumber !== undefined && personalNumber.endsWith('1212121212'))
return this.fakeMode()
this.isFake = false
console.log("initiating login to hjarntorget")
const beginLoginRedirectResponse = await this.fetch('begin-login', beginLoginUrl, {
redirect: 'follow'
})
console.log('initiating login to hjarntorget')
const beginLoginRedirectResponse = await this.fetch(
'begin-login',
beginLoginUrl,
{
redirect: 'follow',
}
)
console.log("prepping??? shibboleth")
const shibbolethLoginResponse = await this.fetch('init-shibboleth-login', shibbolethLoginUrl(shibbolethLoginUrlBase((beginLoginRedirectResponse as any).url)), {
redirect: 'follow'
})
console.log('prepping??? shibboleth')
const shibbolethLoginResponse = await this.fetch(
'init-shibboleth-login',
shibbolethLoginUrl(
shibbolethLoginUrlBase((beginLoginRedirectResponse as any).url)
),
{
redirect: 'follow',
}
)
const shibbolethRedirectUrl = (shibbolethLoginResponse as any).url
console.log("initiating bankid...")
const initBankIdResponse = await this.fetch('init-bankId', initBankIdUrl(shibbolethRedirectUrl), {
redirect: 'follow'
})
console.log('initiating bankid...')
const initBankIdResponse = await this.fetch(
'init-bankId',
initBankIdUrl(shibbolethRedirectUrl),
{
redirect: 'follow',
}
)
const initBankIdResponseText = await initBankIdResponse.text()
const mvghostRequestBody = extractMvghostRequestBody(initBankIdResponseText)
console.log("picking auth server???")
let mvghostResponse = await this.fetch('pick-mvghost', mvghostUrl, {
console.log('picking auth server???')
const mvghostResponse = await this.fetch('pick-mvghost', mvghostUrl, {
redirect: 'follow',
method: 'POST',
body: mvghostRequestBody,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
}
'Content-Type': 'application/x-www-form-urlencoded',
},
})
console.log("start bankid sign in")
console.log('start bankid sign in')
// We may get redirected to some other subdomain i.e. not 'm00-mg-local':
// https://mNN-mg-local.idp.funktionstjanster.se/mg-local/auth/ccp11/grp/other
const ssnBody = new URLSearchParams({ ssn: personalNumber }).toString()
const beginBankIdResponse = await this.fetch('start-bankId', beginBankIdUrl((mvghostResponse as any).url), {
redirect: 'follow',
method: 'POST',
body: ssnBody,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
})
console.log("start polling")
const statusChecker = checkStatus(this.fetch, verifyUrlBase((beginBankIdResponse as any).url))
const ssnBody = new URLSearchParams({ ssn: personalNumber }).toString()
const beginBankIdResponse = await this.fetch(
'start-bankId',
beginBankIdUrl((mvghostResponse as any).url),
{
redirect: 'follow',
method: 'POST',
body: ssnBody,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
)
console.log('start polling')
const statusChecker = checkStatus(
this.fetch,
verifyUrlBase((beginBankIdResponse as any).url)
)
statusChecker.on('OK', async () => {
// setting these similar to how the sthlm api does it
@ -435,7 +509,7 @@ export class ApiHjarntorget extends EventEmitter implements Api {
statusChecker.on('ERROR', () => {
this.personalNumber = undefined
})
return statusChecker
}
@ -451,4 +525,4 @@ export class ApiHjarntorget extends EventEmitter implements Api {
emitter.token = 'fake'
return emitter
}
}
}

View File

@ -1,12 +1,16 @@
import { ApiHjarntorget } from './apiHjarntorget'
import { Api, FetcherOptions, Fetch, RNCookieManager,
import {
Api,
FetcherOptions,
RNCookieManager,
ToughCookieJar,
wrapReactNativeCookieManager,
wrapToughCookie } from '@skolplattformen/api'
wrapToughCookie,
} from '@skolplattformen/api'
import { ApiHjarntorget } from './apiHjarntorget'
export { features } from './features'
const init = (
fetchImpl: Fetch,
fetchImpl: typeof fetch,
cookieManagerImpl: RNCookieManager | ToughCookieJar,
options?: FetcherOptions
): Api => {

View File

@ -1,98 +1,122 @@
import { Fetcher, LoginStatusChecker } from '@skolplattformen/api'
import { EventEmitter } from 'events'
import { LoginStatusChecker, Fetcher} from '@skolplattformen/api'
import {
extractAuthGbgLoginRequestBody,
extractHjarntorgetSAMLLogin
import {
extractAuthGbgLoginRequestBody,
extractHjarntorgetSAMLLogin,
} from './parse/parsers'
import { authGbgLoginUrl, hjarntorgetSAMLLoginUrl, pollStatusUrl } from './routes'
import {
authGbgLoginUrl,
hjarntorgetSAMLLoginUrl,
pollStatusUrl,
} from './routes'
export class HjarntorgetChecker extends EventEmitter {
private fetcher: Fetcher
private fetcher: Fetcher
private basePollingUrl: string
private basePollingUrl: string
public token: string
public token: string
private cancelled = false
private cancelled: boolean = false
constructor(fetcher: Fetcher, basePollingUrl: string) {
super()
this.token = '' // not used, but needed for compatability with the LoginStatusChecker
this.fetcher = fetcher
this.basePollingUrl = basePollingUrl
constructor(fetcher: Fetcher, basePollingUrl: string) {
super()
this.token = '' // not used, but needed for compatability with the LoginStatusChecker
this.fetcher = fetcher
this.basePollingUrl = basePollingUrl
this.check()
}
this.check()
}
async check(): Promise<void> {
try {
console.log('polling bankid signature')
// https://mNN-mg-local.idp.funktionstjanster.se/mg-local/auth/ccp11/grp/pollstatus
async check(): Promise<void> {
try {
console.log("polling bankid signature")
// https://mNN-mg-local.idp.funktionstjanster.se/mg-local/auth/ccp11/grp/pollstatus
const pollStatusResponse = await this.fetcher('poll-bankid-status', pollStatusUrl(this.basePollingUrl))
console.log("poll-bankid-status")
const pollStatusResponseJson = await pollStatusResponse.json()
const pollStatusResponse = await this.fetcher(
'poll-bankid-status',
pollStatusUrl(this.basePollingUrl)
)
console.log('poll-bankid-status')
const pollStatusResponseJson = await pollStatusResponse.json()
const keepPolling = pollStatusResponseJson.infotext !== ''
const isError = pollStatusResponseJson.location.indexOf('error') >= 0
if (!keepPolling && !isError) {
console.log("bankid successfull! follow to location...")
// follow response location to get back to auth.goteborg.se
// r.location is something like:
// 'https://mNN-mg-local.idp.funktionstjanster.se/mg-local/auth/ccp11/grp/signature'
const signatureResponse = await this.fetcher('confirm-signature-redirect', pollStatusResponseJson.location, {
redirect: "follow"
})
if(!signatureResponse.ok) {
throw new Error("Bad signature response")
}
const signatureResponseText = await signatureResponse.text()
const authGbgLoginBody = extractAuthGbgLoginRequestBody(signatureResponseText)
console.log("authGbg saml login")
const authGbgLoginResponse = await this.fetcher('authgbg-saml-login', authGbgLoginUrl, {
redirect: 'follow',
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: authGbgLoginBody
})
if(!authGbgLoginResponse.ok) {
throw new Error("Bad augGbgLogin response")
}
const authGbgLoginResponseText = await authGbgLoginResponse.text()
const hjarntorgetSAMLLoginBody = extractHjarntorgetSAMLLogin(authGbgLoginResponseText)
console.log("hjarntorget saml login")
const hjarntorgetSAMLLoginResponse = await this.fetcher('hjarntorget-saml-login', hjarntorgetSAMLLoginUrl, {
method: 'POST',
redirect: 'follow',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: hjarntorgetSAMLLoginBody,
})
if(!hjarntorgetSAMLLoginResponse.ok) {
throw new Error("Bad hjarntorgetSAMLLogin response")
}
// TODO: add more checks above between calls to see if everything is actually 'OK'...
this.emit('OK')
} else if (isError) {
console.log("polling error")
this.emit('ERROR')
} else if (!this.cancelled && keepPolling) {
console.log("keep on polling...")
this.emit('PENDING')
setTimeout(() => this.check(), 3000)
}
} catch (er) {
console.log('Error validating login to Hjärntorget', er)
this.emit('ERROR')
const keepPolling = pollStatusResponseJson.infotext !== ''
const isError = pollStatusResponseJson.location.indexOf('error') >= 0
if (!keepPolling && !isError) {
console.log('bankid successfull! follow to location...')
// follow response location to get back to auth.goteborg.se
// r.location is something like:
// 'https://mNN-mg-local.idp.funktionstjanster.se/mg-local/auth/ccp11/grp/signature'
const signatureResponse = await this.fetcher(
'confirm-signature-redirect',
pollStatusResponseJson.location,
{
redirect: 'follow',
}
)
if (!signatureResponse.ok) {
throw new Error('Bad signature response')
}
}
const signatureResponseText = await signatureResponse.text()
const authGbgLoginBody = extractAuthGbgLoginRequestBody(
signatureResponseText
)
async cancel(): Promise<void> {
this.cancelled = true
console.log('authGbg saml login')
const authGbgLoginResponse = await this.fetcher(
'authgbg-saml-login',
authGbgLoginUrl,
{
redirect: 'follow',
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: authGbgLoginBody,
}
)
if (!authGbgLoginResponse.ok) {
throw new Error('Bad augGbgLogin response')
}
const authGbgLoginResponseText = await authGbgLoginResponse.text()
const hjarntorgetSAMLLoginBody = extractHjarntorgetSAMLLogin(
authGbgLoginResponseText
)
console.log('hjarntorget saml login')
const hjarntorgetSAMLLoginResponse = await this.fetcher(
'hjarntorget-saml-login',
hjarntorgetSAMLLoginUrl,
{
method: 'POST',
redirect: 'follow',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: hjarntorgetSAMLLoginBody,
}
)
if (!hjarntorgetSAMLLoginResponse.ok) {
throw new Error('Bad hjarntorgetSAMLLogin response')
}
// TODO: add more checks above between calls to see if everything is actually 'OK'...
this.emit('OK')
} else if (isError) {
console.log('polling error')
this.emit('ERROR')
} else if (!this.cancelled && keepPolling) {
console.log('keep on polling...')
this.emit('PENDING')
setTimeout(() => this.check(), 3000)
}
} catch (er) {
console.log('Error validating login to Hjärntorget', er)
this.emit('ERROR')
}
}
async cancel(): Promise<void> {
this.cancelled = true
}
}
export const checkStatus = (fetch: Fetcher, basePollingUrl: string): LoginStatusChecker =>
new HjarntorgetChecker(fetch, basePollingUrl)
export const checkStatus = (
fetch: Fetcher,
basePollingUrl: string
): LoginStatusChecker => new HjarntorgetChecker(fetch, basePollingUrl)

View File

@ -1,32 +1,33 @@
import { EventEmitter } from 'events'
import { decode } from 'he'
import { DateTime } from 'luxon'
import * as html from 'node-html-parser'
import { LoginStatusChecker, FetcherOptions, Fetcher, wrap } from '@skolplattformen/api'
import {
Api,
AuthTicket,
CalendarItem,
Classmate,
CookieManager,
EtjanstChild,
Fetch,
Fetcher,
FetcherOptions,
LoginStatusChecker,
MenuItem,
NewsItem,
Notification,
RequestInit,
Response,
ScheduleItem,
Skola24Child,
SSOSystem,
TimetableEntry,
User,
Response
wrap,
} from '@skolplattformen/api'
import * as routes from './routes'
import * as parse from './parse/index'
import { Language } from '@skolplattformen/curriculum'
import { EventEmitter } from 'events'
import { decode } from 'he'
import { DateTime } from 'luxon'
import * as html from 'node-html-parser'
import * as fake from './fakeData'
import { checkStatus } from './loginStatusChecker'
import { Api } from '@skolplattformen/api'
import { Language } from '@skolplattformen/curriculum'
import * as parse from './parse/index'
import * as routes from './routes'
const fakeResponse = <T>(data: T): Promise<T> =>
new Promise((res) => setTimeout(() => res(data), 200 + Math.random() * 800))
@ -66,7 +67,7 @@ export class ApiSkolplattformen extends EventEmitter implements Api {
private authorizedSystems: SSOSystems = {}
constructor(
fetch: Fetch,
fetch: typeof global.fetch,
cookieManager: CookieManager,
options?: FetcherOptions
) {
@ -286,10 +287,17 @@ export class ApiSkolplattformen extends EventEmitter implements Api {
}
// eslint-disable-next-line class-methods-use-this
private CheckResponseForCorrectChildStatus(response: Response, child: EtjanstChild) {
const setCookieResp = response.headers.get("Set-Cookie")
private CheckResponseForCorrectChildStatus(
response: Response,
child: EtjanstChild
) {
const setCookieResp = response.headers.get('Set-Cookie')
if (child.status !== 'FS' && setCookieResp && setCookieResp.includes("Status=FS")) {
if (
child.status !== 'FS' &&
setCookieResp &&
setCookieResp.includes('Status=FS')
) {
throw new Error('Wrong child in response')
}
}
@ -297,7 +305,7 @@ export class ApiSkolplattformen extends EventEmitter implements Api {
public async getNewsDetails(
child: EtjanstChild,
item: NewsItem
): Promise<NewsItem | undefined > {
): Promise<NewsItem | undefined> {
if (this.isFake) {
return fakeResponse(fake.news(child).find((ni) => ni.id === item.id))
}

View File

@ -1,12 +1,16 @@
import { ApiSkolplattformen } from './api'
import { Api, FetcherOptions, Fetch, RNCookieManager,
import {
Api,
FetcherOptions,
RNCookieManager,
ToughCookieJar,
wrapReactNativeCookieManager,
wrapToughCookie } from '@skolplattformen/api'
wrapToughCookie,
} from '@skolplattformen/api'
import { ApiSkolplattformen } from './api'
export { features } from './features'
const init = (
fetchImpl: Fetch,
fetchImpl: typeof fetch,
cookieManagerImpl: RNCookieManager | ToughCookieJar,
options?: FetcherOptions
): Api => {

View File

@ -1,4 +1,4 @@
import { Fetch, RequestInit, Response } from './types'
import { Response } from './types'
export interface CallInfo extends RequestInit {
name: string
@ -48,7 +48,7 @@ const record = async (
}
export default function wrap(
fetch: Fetch,
fetch: typeof global.fetch,
options: FetcherOptions = {}
): Fetcher {
return async (

View File

@ -20,15 +20,6 @@ export interface CookieManager {
removeAllCookies?: () => Promise<void>
}
export interface RequestInit {
headers?: any
method?: string
body?: string
/**
* Set to `manual` to extract redirect headers, `error` to reject redirect */
redirect?: string
}
export interface Headers {
get(name: string): string | null
}
@ -42,10 +33,6 @@ export interface Response {
json: () => Promise<any>
}
export interface Fetch {
(url: string, init?: RequestInit): Promise<Response>
}
export interface AuthTicket {
order: string
token: string